Coverage priorities 1-5: test pure functions, extract interfaces, mock-based handler tests
- 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>
This commit is contained in:
359
internal/push/push_coverage_test.go
Normal file
359
internal/push/push_coverage_test.go
Normal file
@@ -0,0 +1,359 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user