- Priority 1: Test NewSendEmailTask + NewSendPushTask (5 tests) - Priority 2: Test customHTTPErrorHandler — all 15+ branches (21 tests) - Priority 3: Extract Enqueuer interface + payload builders in worker pkg (5 tests) - Priority 4: Extract ClassifyFile/ComputeRelPath in migrate-encrypt (6 tests) - Priority 5: Define Handler interfaces, refactor to accept them, mock-based tests (14 tests) - Fix .gitignore: /worker instead of worker to stop ignoring internal/worker/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
360 lines
10 KiB
Go
360 lines
10 KiB
Go
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)
|
|
}
|