package handler import ( "context" "net/http/httptest" "strings" "testing" "time" "yingce/backend/internal/model" "yingce/backend/internal/repository" "yingce/backend/internal/service" "github.com/gin-gonic/gin" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func TestTaskTextEventCursorPrefersQueryAndSupportsLastEventID(t *testing.T) { gin.SetMode(gin.TestMode) request := httptest.NewRequest("GET", "/api/tasks/task-1/text-events?after=7", nil) request.Header.Set("Last-Event-ID", "3") context, _ := gin.CreateTestContext(httptest.NewRecorder()) context.Request = request after, err := taskTextEventCursor(context) if err != nil || after != 7 { t.Fatalf("cursor = %d, err = %v", after, err) } request = httptest.NewRequest("GET", "/api/tasks/task-1/text-events", nil) request.Header.Set("Last-Event-ID", "3") context, _ = gin.CreateTestContext(httptest.NewRecorder()) context.Request = request after, err = taskTextEventCursor(context) if err != nil || after != 3 { t.Fatalf("Last-Event-ID cursor = %d, err = %v", after, err) } } func TestTaskTextEventCursorRejectsInvalidValues(t *testing.T) { gin.SetMode(gin.TestMode) for _, query := range []string{"after=abc", "after=-1"} { t.Run(query, func(t *testing.T) { request := httptest.NewRequest("GET", "/api/tasks/task-1/text-events?"+query, nil) context, _ := gin.CreateTestContext(httptest.NewRecorder()) context.Request = request if after, err := taskTextEventCursor(context); err == nil || after != 0 { t.Fatalf("cursor = %d, err = %v; want a rejected cursor", after, err) } }) } } func TestStreamTaskTextEventsCachedReplayReachesTerminalWithoutDuplicateDelta(t *testing.T) { t.Setenv("REDIS_URL", "") db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatal(err) } if err := db.AutoMigrate(&model.Task{}, &model.TaskTextDelta{}); err != nil { t.Fatal(err) } if err := db.Create(&model.Task{ID: "task", UserID: "user", Status: model.TaskStatusRunning}).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.TaskTextDelta{ID: "delta", UserID: "user", TaskID: "task", Sequence: 8, Content: "text", ExpiresAt: time.Now().Add(time.Hour)}).Error; err != nil { t.Fatal(err) } svc := service.New(repository.New(db), t.TempDir()) t.Cleanup(func() { _ = svc.Close() }) ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) defer cancel() initial, err := svc.CachedTaskTextReplay(ctx, "user", "task", 7) if err != nil { t.Fatal(err) } if err := db.Model(&model.Task{}).Where("id = ?", "task").Updates(map[string]any{"status": model.TaskStatusSucceeded, "progress": 100}).Error; err != nil { t.Fatal(err) } response := httptest.NewRecorder() c, _ := gin.CreateTestContext(response) c.Request = httptest.NewRequest("GET", "/api/tasks/task/text-events", nil).WithContext(ctx) streamTaskTextEvents(c, svc, "user", "task", 7, initial) body := response.Body.String() if strings.Count(body, "id: 8\nevent: delta") != 1 || !strings.Contains(body, "event: terminal") || !strings.Contains(body, `"progress":100`) { t.Fatalf("unexpected cached SSE stream: %q", body) } } func TestStreamTaskTextEventsWritesSequenceAndTerminalEvent(t *testing.T) { gin.SetMode(gin.TestMode) response := httptest.NewRecorder() context, _ := gin.CreateTestContext(response) context.Request = httptest.NewRequest("GET", "/api/tasks/task-1/text-events", nil) replay := &service.TextReplayResult{ Deltas: []model.TaskTextDelta{{Sequence: 8, Content: "增量"}}, Complete: true, FinalText: "增量", Status: model.TaskStatusSucceeded, Stage: "已完成", Progress: 100, } streamTaskTextEvents(context, nil, "user-1", "task-1", 7, replay) if response.Header().Get("Content-Type") != "text/event-stream; charset=utf-8" { t.Fatalf("content type = %q", response.Header().Get("Content-Type")) } body := response.Body.String() if !strings.Contains(body, "event: progress") || !strings.Contains(body, `"progress":100`) || !strings.Contains(body, "id: 8\nevent: delta") || !strings.Contains(body, "event: terminal") { t.Fatalf("unexpected SSE body: %q", body) } }