package push import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // === truncateToken tests === func TestTruncateToken_LongToken(t *testing.T) { token := "abcdefghijklmnopqrstuvwxyz1234567890" result := truncateToken(token) assert.Equal(t, "abcdefgh...", result) } func TestTruncateToken_ShortToken(t *testing.T) { token := "abc" result := truncateToken(token) assert.Equal(t, "abc", result) } func TestTruncateToken_ExactlyEightChars(t *testing.T) { token := "12345678" result := truncateToken(token) assert.Equal(t, "12345678", result) } func TestTruncateToken_NineChars(t *testing.T) { token := "123456789" result := truncateToken(token) assert.Equal(t, "12345678...", result) } func TestTruncateToken_Empty(t *testing.T) { result := truncateToken("") assert.Equal(t, "", result) } // === Client tests === func TestClient_SendToIOS_Disabled(t *testing.T) { client := &Client{ enabled: false, apnsBreaker: NewCircuitBreaker("apns"), fcmBreaker: NewCircuitBreaker("fcm"), } err := client.SendToIOS(context.Background(), []string{"token1"}, "Title", "Body", nil) assert.NoError(t, err) // Returns nil when disabled } func TestClient_SendToIOS_NilAPNs(t *testing.T) { client := &Client{ enabled: true, apns: nil, // Not initialized apnsBreaker: NewCircuitBreaker("apns"), fcmBreaker: NewCircuitBreaker("fcm"), } err := client.SendToIOS(context.Background(), []string{"token1"}, "Title", "Body", nil) assert.NoError(t, err) // Returns nil when not initialized } func TestClient_SendToIOS_CircuitBreakerOpen(t *testing.T) { breaker := NewCircuitBreaker("apns", WithFailureThreshold(1)) breaker.RecordFailure() // Open the circuit client := &Client{ enabled: true, apns: &APNsClient{}, // Non-nil so we pass the nil check apnsBreaker: breaker, fcmBreaker: NewCircuitBreaker("fcm"), } err := client.SendToIOS(context.Background(), []string{"token1"}, "Title", "Body", nil) assert.ErrorIs(t, err, ErrCircuitOpen) } func TestClient_SendToAndroid_Disabled(t *testing.T) { client := &Client{ enabled: false, apnsBreaker: NewCircuitBreaker("apns"), fcmBreaker: NewCircuitBreaker("fcm"), } err := client.SendToAndroid(context.Background(), []string{"token1"}, "Title", "Body", nil) assert.NoError(t, err) } func TestClient_SendToAndroid_NilFCM(t *testing.T) { client := &Client{ enabled: true, fcm: nil, apnsBreaker: NewCircuitBreaker("apns"), fcmBreaker: NewCircuitBreaker("fcm"), } err := client.SendToAndroid(context.Background(), []string{"token1"}, "Title", "Body", nil) assert.NoError(t, err) } func TestClient_SendToAndroid_CircuitBreakerOpen(t *testing.T) { breaker := NewCircuitBreaker("fcm", WithFailureThreshold(1)) breaker.RecordFailure() client := &Client{ enabled: true, fcm: &FCMClient{}, // Non-nil apnsBreaker: NewCircuitBreaker("apns"), fcmBreaker: breaker, } err := client.SendToAndroid(context.Background(), []string{"token1"}, "Title", "Body", nil) assert.ErrorIs(t, err, ErrCircuitOpen) } func TestClient_SendToAndroid_Success(t *testing.T) { server := serveFCMV1Success(t) defer server.Close() fcmClient := newTestFCMClient(server.URL) client := &Client{ enabled: true, fcm: fcmClient, apnsBreaker: NewCircuitBreaker("apns"), fcmBreaker: NewCircuitBreaker("fcm"), } err := client.SendToAndroid(context.Background(), []string{"token1"}, "Title", "Body", nil) assert.NoError(t, err) } func TestClient_SendToAndroid_Failure_RecordsInBreaker(t *testing.T) { server := serveFCMV1Error(t, http.StatusInternalServerError, "INTERNAL", "internal error") defer server.Close() fcmClient := newTestFCMClient(server.URL) breaker := NewCircuitBreaker("fcm", WithFailureThreshold(3)) client := &Client{ enabled: true, fcm: fcmClient, apnsBreaker: NewCircuitBreaker("apns"), fcmBreaker: breaker, } err := client.SendToAndroid(context.Background(), []string{"token1"}, "Title", "Body", nil) assert.Error(t, err) assert.Equal(t, 1, breaker.Counts()) } func TestClient_SendToAll_Disabled(t *testing.T) { client := &Client{ enabled: false, apnsBreaker: NewCircuitBreaker("apns"), fcmBreaker: NewCircuitBreaker("fcm"), } err := client.SendToAll(context.Background(), []string{"ios-token"}, []string{"android-token"}, "Title", "Body", nil) assert.NoError(t, err) } func TestClient_SendToAll_EmptyTokens(t *testing.T) { server := serveFCMV1Success(t) defer server.Close() fcmClient := newTestFCMClient(server.URL) client := &Client{ enabled: true, fcm: fcmClient, apnsBreaker: NewCircuitBreaker("apns"), fcmBreaker: NewCircuitBreaker("fcm"), } // No tokens at all — should just return nil err := client.SendToAll(context.Background(), []string{}, []string{}, "Title", "Body", nil) assert.NoError(t, err) } func TestClient_SendToAll_AndroidOnly(t *testing.T) { server := serveFCMV1Success(t) defer server.Close() fcmClient := newTestFCMClient(server.URL) client := &Client{ enabled: true, fcm: fcmClient, apnsBreaker: NewCircuitBreaker("apns"), fcmBreaker: NewCircuitBreaker("fcm"), } err := client.SendToAll(context.Background(), []string{}, []string{"android-token"}, "Title", "Body", nil) assert.NoError(t, err) } func TestClient_IsIOSEnabled(t *testing.T) { clientWithAPNS := &Client{apns: &APNsClient{}} clientWithoutAPNS := &Client{apns: nil} assert.True(t, clientWithAPNS.IsIOSEnabled()) assert.False(t, clientWithoutAPNS.IsIOSEnabled()) } func TestClient_IsAndroidEnabled(t *testing.T) { clientWithFCM := &Client{fcm: &FCMClient{}} clientWithoutFCM := &Client{fcm: nil} assert.True(t, clientWithFCM.IsAndroidEnabled()) assert.False(t, clientWithoutFCM.IsAndroidEnabled()) } func TestClient_HealthCheck(t *testing.T) { client := &Client{ apns: nil, fcm: nil, } err := client.HealthCheck(context.Background()) assert.NoError(t, err) client.fcm = &FCMClient{} err = client.HealthCheck(context.Background()) assert.NoError(t, err) } func TestClient_SendActionableNotification_Disabled(t *testing.T) { client := &Client{ enabled: false, apnsBreaker: NewCircuitBreaker("apns"), fcmBreaker: NewCircuitBreaker("fcm"), } err := client.SendActionableNotification(context.Background(), []string{"ios"}, []string{"android"}, "Title", "Body", nil, "TASK_DUE") assert.NoError(t, err) } func TestClient_SendActionableNotification_NilAPNs(t *testing.T) { server := serveFCMV1Success(t) defer server.Close() fcmClient := newTestFCMClient(server.URL) client := &Client{ enabled: true, apns: nil, fcm: fcmClient, apnsBreaker: NewCircuitBreaker("apns"), fcmBreaker: NewCircuitBreaker("fcm"), } // Should skip iOS and send Android err := client.SendActionableNotification(context.Background(), []string{"ios"}, []string{"android"}, "Title", "Body", nil, "TASK_DUE") assert.NoError(t, err) } func TestClient_SendActionableNotification_APNsBreakerOpen(t *testing.T) { server := serveFCMV1Success(t) defer server.Close() fcmClient := newTestFCMClient(server.URL) apnsBreaker := NewCircuitBreaker("apns", WithFailureThreshold(1)) apnsBreaker.RecordFailure() client := &Client{ enabled: true, apns: &APNsClient{}, fcm: fcmClient, apnsBreaker: apnsBreaker, fcmBreaker: NewCircuitBreaker("fcm"), } err := client.SendActionableNotification(context.Background(), []string{"ios"}, []string{"android"}, "Title", "Body", nil, "TASK_DUE") // Should return ErrCircuitOpen because that was the lastErr set assert.ErrorIs(t, err, ErrCircuitOpen) } // === FCM additional tests === func TestFCMV1Send_WithDataPayload(t *testing.T) { var receivedData map[string]string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req fcmV1Request _ = json.NewDecoder(r.Body).Decode(&req) receivedData = req.Message.Data w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(fcmV1Response{Name: "projects/test/messages/0:12345"}) })) defer server.Close() client := newTestFCMClient(server.URL) data := map[string]string{ "task_id": "42", "action": "complete", "deep_link": "/tasks/42", } err := client.Send(context.Background(), []string{"token"}, "Title", "Body", data) require.NoError(t, err) assert.Equal(t, "42", receivedData["task_id"]) assert.Equal(t, "complete", receivedData["action"]) assert.Equal(t, "/tasks/42", receivedData["deep_link"]) } func TestFCMV1Send_ContextCancelled(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // The server just hangs — context cancellation should cause the request to fail select {} })) defer server.Close() client := newTestFCMClient(server.URL) ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately err := client.Send(ctx, []string{"token"}, "Title", "Body", nil) assert.Error(t, err) } func TestFCMSendError_ErrorFormatting(t *testing.T) { // Short token (no truncation) shortErr := &FCMSendError{ Token: "abc", StatusCode: 500, ErrorCode: FCMErrInternal, Message: "server error", } assert.Contains(t, shortErr.Error(), "abc") assert.Contains(t, shortErr.Error(), "500") assert.Contains(t, shortErr.Error(), "INTERNAL") assert.Contains(t, shortErr.Error(), "server error") } func TestParseFCMV1Error_MalformedJSON(t *testing.T) { result := parseFCMV1Error("token123", 500, []byte("not json")) assert.Equal(t, 500, result.StatusCode) assert.Contains(t, result.Message, "unparseable error response") } func TestParseFCMV1Error_ValidJSON(t *testing.T) { body := `{"error":{"code":404,"message":"not found","status":"NOT_FOUND"}}` result := parseFCMV1Error("token123", 404, []byte(body)) assert.Equal(t, 404, result.StatusCode) assert.Equal(t, FCMErrorCode("NOT_FOUND"), result.ErrorCode) assert.Equal(t, "not found", result.Message) } // === Platform constants === func TestPlatformConstants(t *testing.T) { assert.Equal(t, "ios", PlatformIOS) assert.Equal(t, "android", PlatformAndroid) }