package push import ( "context" "encoding/json" "net/http" "net/http/httptest" "sync" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // newTestFCMClient creates an FCMClient whose endpoint points at the given test // server URL. The HTTP client uses no OAuth transport so tests can run without // real Google credentials. func newTestFCMClient(serverURL string) *FCMClient { return &FCMClient{ projectID: "test-project", endpoint: serverURL, httpClient: &http.Client{}, } } // serveFCMV1Success creates a test server that returns a successful v1 response // for every request. func serveFCMV1Success(t *testing.T) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Verify it looks like a v1 request. var req fcmV1Request err := json.NewDecoder(r.Body).Decode(&req) require.NoError(t, err) require.NotNil(t, req.Message) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) resp := fcmV1Response{Name: "projects/test-project/messages/0:12345"} _ = json.NewEncoder(w).Encode(resp) })) } // serveFCMV1Error creates a test server that returns the given status code and // a structured v1 error response for every request. func serveFCMV1Error(t *testing.T, statusCode int, errStatus string, errMessage string) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) resp := fcmV1ErrorResponse{ Error: fcmV1Error{ Code: statusCode, Message: errMessage, Status: errStatus, }, } _ = json.NewEncoder(w).Encode(resp) })) } func TestFCMV1Send_Success_SingleToken(t *testing.T) { server := serveFCMV1Success(t) defer server.Close() client := newTestFCMClient(server.URL) err := client.Send(context.Background(), []string{"token-aaa-111"}, "Title", "Body", nil) assert.NoError(t, err) } func TestFCMV1Send_Success_MultipleTokens(t *testing.T) { var mu sync.Mutex receivedTokens := make([]string, 0) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req fcmV1Request _ = json.NewDecoder(r.Body).Decode(&req) mu.Lock() receivedTokens = append(receivedTokens, req.Message.Token) mu.Unlock() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) resp := fcmV1Response{Name: "projects/test-project/messages/0:12345"} _ = json.NewEncoder(w).Encode(resp) })) defer server.Close() client := newTestFCMClient(server.URL) tokens := []string{"token-aaa-111", "token-bbb-222", "token-ccc-333"} err := client.Send(context.Background(), tokens, "Title", "Body", map[string]string{"key": "value"}) assert.NoError(t, err) assert.ElementsMatch(t, tokens, receivedTokens) } func TestFCMV1Send_EmptyTokens_ReturnsNil(t *testing.T) { client := &FCMClient{ projectID: "test-project", endpoint: "http://unused", httpClient: &http.Client{}, } err := client.Send(context.Background(), []string{}, "Title", "Body", nil) assert.NoError(t, err) } func TestFCMV1Send_AllFail_ReturnsError(t *testing.T) { server := serveFCMV1Error(t, http.StatusNotFound, "UNREGISTERED", "The registration token is not registered") defer server.Close() client := newTestFCMClient(server.URL) tokens := []string{"bad-token-1", "bad-token-2"} err := client.Send(context.Background(), tokens, "Title", "Body", nil) assert.Error(t, err) assert.Contains(t, err.Error(), "all FCM notifications failed") } func TestFCMV1Send_PartialFailure_ReturnsNil(t *testing.T) { var mu sync.Mutex callCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() callCount++ n := callCount mu.Unlock() w.Header().Set("Content-Type", "application/json") if n == 1 { // First token succeeds w.WriteHeader(http.StatusOK) resp := fcmV1Response{Name: "projects/test-project/messages/0:12345"} _ = json.NewEncoder(w).Encode(resp) } else { // Second token fails w.WriteHeader(http.StatusNotFound) resp := fcmV1ErrorResponse{ Error: fcmV1Error{Code: 404, Message: "not registered", Status: "UNREGISTERED"}, } _ = json.NewEncoder(w).Encode(resp) } })) defer server.Close() client := newTestFCMClient(server.URL) tokens := []string{"good-token", "bad-token"} // Partial failure: at least one succeeded, so no error returned. err := client.Send(context.Background(), tokens, "Title", "Body", nil) assert.NoError(t, err) } func TestFCMV1Send_UnregisteredError(t *testing.T) { server := serveFCMV1Error(t, http.StatusNotFound, "UNREGISTERED", "The registration token is not registered") defer server.Close() client := newTestFCMClient(server.URL) err := client.sendOne(context.Background(), "stale-token", "Title", "Body", nil) require.Error(t, err) var sendErr *FCMSendError require.ErrorAs(t, err, &sendErr) assert.True(t, sendErr.IsUnregistered()) assert.Equal(t, FCMErrUnregistered, sendErr.ErrorCode) assert.Equal(t, http.StatusNotFound, sendErr.StatusCode) } func TestFCMV1Send_QuotaExceededError(t *testing.T) { server := serveFCMV1Error(t, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Sending quota exceeded") defer server.Close() client := newTestFCMClient(server.URL) err := client.sendOne(context.Background(), "some-token", "Title", "Body", nil) require.Error(t, err) var sendErr *FCMSendError require.ErrorAs(t, err, &sendErr) assert.Equal(t, FCMErrQuotaExceeded, sendErr.ErrorCode) assert.Equal(t, http.StatusTooManyRequests, sendErr.StatusCode) } func TestFCMV1Send_UnparseableErrorResponse(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte("not json at all")) })) defer server.Close() client := newTestFCMClient(server.URL) err := client.sendOne(context.Background(), "some-token", "Title", "Body", nil) require.Error(t, err) var sendErr *FCMSendError require.ErrorAs(t, err, &sendErr) assert.Equal(t, http.StatusInternalServerError, sendErr.StatusCode) assert.Contains(t, sendErr.Message, "unparseable error response") } func TestFCMV1Send_RequestPayloadFormat(t *testing.T) { var receivedReq fcmV1Request server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Verify Content-Type header assert.Equal(t, "application/json", r.Header.Get("Content-Type")) assert.Equal(t, http.MethodPost, r.Method) err := json.NewDecoder(r.Body).Decode(&receivedReq) require.NoError(t, err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(fcmV1Response{Name: "projects/test-project/messages/0:12345"}) })) defer server.Close() client := newTestFCMClient(server.URL) data := map[string]string{"task_id": "42", "action": "complete"} err := client.Send(context.Background(), []string{"device-token-xyz"}, "Task Due", "Your task is due today", data) require.NoError(t, err) // Verify the v1 message structure require.NotNil(t, receivedReq.Message) assert.Equal(t, "device-token-xyz", receivedReq.Message.Token) assert.Equal(t, "Task Due", receivedReq.Message.Notification.Title) assert.Equal(t, "Your task is due today", receivedReq.Message.Notification.Body) assert.Equal(t, "42", receivedReq.Message.Data["task_id"]) assert.Equal(t, "complete", receivedReq.Message.Data["action"]) assert.Equal(t, "HIGH", receivedReq.Message.Android.Priority) } func TestFCMSendError_Error(t *testing.T) { sendErr := &FCMSendError{ Token: "abcdef1234567890", StatusCode: 404, ErrorCode: FCMErrUnregistered, Message: "token not registered", } errStr := sendErr.Error() assert.Contains(t, errStr, "abcdef12...") assert.Contains(t, errStr, "token not registered") assert.Contains(t, errStr, "404") assert.Contains(t, errStr, "UNREGISTERED") } func TestFCMSendError_IsUnregistered(t *testing.T) { tests := []struct { name string code FCMErrorCode expected bool }{ {"unregistered", FCMErrUnregistered, true}, {"quota_exceeded", FCMErrQuotaExceeded, false}, {"internal", FCMErrInternal, false}, {"empty", "", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := &FCMSendError{ErrorCode: tt.code} assert.Equal(t, tt.expected, err.IsUnregistered()) }) } }