Initial commit: email-openclaw bridge v1
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>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
package ai_client_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"thuanle.me/claw-email-bridge/internal/ai_client"
|
||||
"thuanle.me/claw-email-bridge/internal/config"
|
||||
"thuanle.me/claw-email-bridge/internal/database"
|
||||
)
|
||||
|
||||
// Test matrix #2: OpenClaw fail 2 lần, lần 3 thành công → COMPLETED, attempt_openclaw = 3.
|
||||
func TestDispatch_RetryThenSuccess(t *testing.T) {
|
||||
tdb := database.NewTestDB(t)
|
||||
|
||||
var callCount atomic.Int32
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := callCount.Add(1)
|
||||
if n <= 2 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("temporary error"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
OpenClawURL: mockServer.URL,
|
||||
ListenAddr: ":9999",
|
||||
}
|
||||
|
||||
task := &database.Task{
|
||||
TaskUUID: "uuid-retry-test",
|
||||
ThreadID: "thread-1",
|
||||
MessageID: "msg-retry@test.com",
|
||||
Sender: "user@test.com",
|
||||
Subject: "Retry test",
|
||||
BodyPlain: "Hello",
|
||||
Status: database.StatusReceived,
|
||||
}
|
||||
if err := database.CreateTask(tdb.DB, task); err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
|
||||
dispatcher := ai_client.NewDispatcher(cfg, tdb.DB)
|
||||
dispatcher.Dispatch(task) // Synchronous call for testing.
|
||||
|
||||
// Verify 3 attempts were made.
|
||||
if callCount.Load() != 3 {
|
||||
t.Errorf("expected 3 API calls, got %d", callCount.Load())
|
||||
}
|
||||
|
||||
// Reload task from DB.
|
||||
updated, err := database.FindByTaskUUID(tdb.DB, "uuid-retry-test")
|
||||
if err != nil {
|
||||
t.Fatalf("find task: %v", err)
|
||||
}
|
||||
if updated.Status != database.StatusAIProcessing {
|
||||
t.Errorf("expected AI_PROCESSING (waiting for callback), got %s", updated.Status)
|
||||
}
|
||||
if updated.AttemptOpenClaw != 3 {
|
||||
t.Errorf("expected attempt_openclaw = 3, got %d", updated.AttemptOpenClaw)
|
||||
}
|
||||
}
|
||||
|
||||
// OpenClaw fail all 3 retries → FAILED with last_error.
|
||||
func TestDispatch_AllRetriesFailed(t *testing.T) {
|
||||
tdb := database.NewTestDB(t)
|
||||
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("server down"))
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
OpenClawURL: mockServer.URL,
|
||||
ListenAddr: ":9999",
|
||||
}
|
||||
|
||||
task := &database.Task{
|
||||
TaskUUID: "uuid-fail-all",
|
||||
ThreadID: "thread-1",
|
||||
MessageID: "msg-fail@test.com",
|
||||
Sender: "user@test.com",
|
||||
Subject: "Fail test",
|
||||
BodyPlain: "Hello",
|
||||
Status: database.StatusReceived,
|
||||
}
|
||||
if err := database.CreateTask(tdb.DB, task); err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
|
||||
dispatcher := ai_client.NewDispatcher(cfg, tdb.DB)
|
||||
dispatcher.Dispatch(task)
|
||||
|
||||
updated, err := database.FindByTaskUUID(tdb.DB, "uuid-fail-all")
|
||||
if err != nil {
|
||||
t.Fatalf("find task: %v", err)
|
||||
}
|
||||
if updated.Status != database.StatusFailed {
|
||||
t.Errorf("expected FAILED, got %s", updated.Status)
|
||||
}
|
||||
if updated.LastError == nil {
|
||||
t.Error("expected last_error to be set")
|
||||
}
|
||||
if updated.AttemptOpenClaw != 3 {
|
||||
t.Errorf("expected attempt_openclaw = 3, got %d", updated.AttemptOpenClaw)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user