package api_test import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "thuanle.me/claw-email-bridge/internal/api" "thuanle.me/claw-email-bridge/internal/database" ) const testToken = "test-secret-token" func setupTestRouter(t *testing.T) (*database.TestDB, *httptest.Server) { t.Helper() tdb := database.NewTestDB(t) router := api.NewRouter(tdb.DB, testToken, nil) srv := httptest.NewServer(router) t.Cleanup(func() { srv.Close() }) return tdb, srv } // Test matrix #4: Callback sai token → 401, state không đổi. func TestCallback_WrongToken_Returns401(t *testing.T) { tdb, srv := setupTestRouter(t) // Create a task in AI_PROCESSING state. task := &database.Task{ TaskUUID: "uuid-wrong-token", ThreadID: "thread-1", MessageID: "msg-1", Sender: "user@test.com", Subject: "Test", Status: database.StatusAIProcessing, } if err := database.CreateTask(tdb.DB, task); err != nil { t.Fatalf("create task: %v", err) } payload := map[string]any{ "metadata": map[string]string{"task_uuid": "uuid-wrong-token"}, "result": "AI response", "status": "ok", } body, _ := json.Marshal(payload) // No token. resp, err := http.Post(srv.URL+"/callback", "application/json", bytes.NewReader(body)) if err != nil { t.Fatalf("request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Errorf("expected 401, got %d", resp.StatusCode) } // Wrong token. req, _ := http.NewRequest("POST", srv.URL+"/callback", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Bridge-Token", "wrong-token") resp2, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("request failed: %v", err) } defer resp2.Body.Close() if resp2.StatusCode != http.StatusUnauthorized { t.Errorf("expected 401, got %d", resp2.StatusCode) } // Verify state unchanged. updated, err := database.FindByTaskUUID(tdb.DB, "uuid-wrong-token") if err != nil { t.Fatalf("find task: %v", err) } if updated.Status != database.StatusAIProcessing { t.Errorf("expected status AI_PROCESSING, got %s", updated.Status) } if updated.AIResponse != "" { t.Errorf("expected empty ai_response, got %q", updated.AIResponse) } } // Test matrix #1 (partial): callback hợp lệ → CALLBACK_DONE. func TestCallback_ValidToken_UpdatesTask(t *testing.T) { tdb, srv := setupTestRouter(t) task := &database.Task{ TaskUUID: "uuid-valid", ThreadID: "thread-1", MessageID: "msg-1", Sender: "user@test.com", Subject: "Test", Status: database.StatusAIProcessing, } if err := database.CreateTask(tdb.DB, task); err != nil { t.Fatalf("create task: %v", err) } payload := map[string]any{ "metadata": map[string]string{"task_uuid": "uuid-valid"}, "result": "Hello from AI", "status": "ok", } body, _ := json.Marshal(payload) req, _ := http.NewRequest("POST", srv.URL+"/callback", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Bridge-Token", testToken) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("expected 200, got %d", resp.StatusCode) } updated, _ := database.FindByTaskUUID(tdb.DB, "uuid-valid") if updated.Status != database.StatusCallbackDone { t.Errorf("expected CALLBACK_DONE, got %s", updated.Status) } if updated.AIResponse != "Hello from AI" { t.Errorf("expected 'Hello from AI', got %q", updated.AIResponse) } } // Idempotent callback: task already COMPLETED → 200, no changes. func TestCallback_AlreadyCompleted_Idempotent(t *testing.T) { tdb, srv := setupTestRouter(t) task := &database.Task{ TaskUUID: "uuid-completed", ThreadID: "thread-1", MessageID: "msg-1", Sender: "user@test.com", Subject: "Test", Status: database.StatusCompleted, AIResponse: "Original response", } if err := database.CreateTask(tdb.DB, task); err != nil { t.Fatalf("create task: %v", err) } payload := map[string]any{ "metadata": map[string]string{"task_uuid": "uuid-completed"}, "result": "New response that should be ignored", "status": "ok", } body, _ := json.Marshal(payload) req, _ := http.NewRequest("POST", srv.URL+"/callback", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Bridge-Token", testToken) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("expected 200, got %d", resp.StatusCode) } updated, _ := database.FindByTaskUUID(tdb.DB, "uuid-completed") if updated.AIResponse != "Original response" { t.Errorf("expected original response preserved, got %q", updated.AIResponse) } } // Health endpoints. func TestHealthz(t *testing.T) { _, srv := setupTestRouter(t) resp, err := http.Get(srv.URL + "/healthz") if err != nil { t.Fatalf("request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("expected 200, got %d", resp.StatusCode) } } func TestReadyz(t *testing.T) { _, srv := setupTestRouter(t) resp, err := http.Get(srv.URL + "/readyz") if err != nil { t.Fatalf("request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("expected 200, got %d", resp.StatusCode) } }