Full pipeline: IMAP ingress -> OpenClaw dispatch -> callback -> SMTP reply. SQLite stateful storage with idempotency, threading, and retry logic. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
199 lines
5.3 KiB
Go
199 lines
5.3 KiB
Go
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)
|
|
}
|
|
}
|