package inbox import ( "bytes" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "github.com/google/uuid" "github.com/stretchr/testify/require" "gorm.io/gorm" "senlinai-agent/backend/internal/config" "senlinai-agent/backend/internal/httpx" "senlinai-agent/backend/internal/models" ) type inboxTestFixture struct { database *gorm.DB owner models.SenlinAgentUser other models.SenlinAgentUser project models.SenlinAgentProject } type inboxSuggestionResponse struct { ID string `json:"id"` Kind string `json:"kind"` Title string `json:"title"` Body string `json:"body"` } type inboxAnalyzeResponse struct { Suggestions []inboxSuggestionResponse `json:"suggestions"` } func TestStaticAnalyzerBuildsReviewableDraftsFromInboxContent(t *testing.T) { item := models.SenlinAgentInboxItem{Title: "客户访谈", Body: "客户希望下周确认交付计划。"} suggestions, err := (StaticAnalyzer{}).Analyze(item, 7) require.NoError(t, err) require.Len(t, suggestions, 3) require.Equal(t, []string{"task", "note", "source"}, []string{suggestions[0].Kind, suggestions[1].Kind, suggestions[2].Kind}) for _, suggestion := range suggestions { require.Contains(t, suggestion.Title, "客户访谈") require.Equal(t, item.Body, suggestion.Body) } } func TestInboxCaptureUsesOwnedUUIDv7AndCamelCaseDTO(t *testing.T) { fixture := newInboxTestFixture(t) router := fixture.router(fixture.owner.ID, StaticAnalyzer{}) response := performInboxJSON(t, router, http.MethodPost, "/api/v1/projects/"+fixture.project.Identity+"/inbox", gin.H{ "sourceType": "text", "title": "客户回访", "body": "整理会议记录", }) require.Equal(t, http.StatusCreated, response.Code) var payload map[string]any require.NoError(t, json.Unmarshal(response.Body.Bytes(), &payload)) require.ElementsMatch(t, []string{"id", "projectId", "sourceType", "title", "body", "status", "createdAt", "updatedAt"}, inboxMapKeys(payload)) require.Equal(t, fixture.project.Identity, payload["projectId"]) require.Equal(t, "text", payload["sourceType"]) require.Equal(t, "open", payload["status"]) requireUUIDv7(t, payload["id"].(string)) var item models.SenlinAgentInboxItem require.NoError(t, fixture.database.Where("identity = ?", payload["id"]).First(&item).Error) require.Equal(t, fixture.owner.ID, item.CreatedBy) require.Equal(t, fixture.owner.Identity, item.CreatedByIdentity) require.Equal(t, fixture.project.ID, item.ProjectID) require.Equal(t, fixture.project.Identity, item.ProjectIdentity) } func TestInboxCaptureRejectsNumericAndUnownedProjectIdentities(t *testing.T) { fixture := newInboxTestFixture(t) router := fixture.router(fixture.owner.ID, StaticAnalyzer{}) numeric := performInboxJSON(t, router, http.MethodPost, "/api/v1/projects/1/inbox", gin.H{ "sourceType": "text", "body": "numeric path", }) require.Equal(t, http.StatusBadRequest, numeric.Code) otherProject := models.SenlinAgentProject{OwnerID: fixture.other.ID, Name: "Other", Identifier: "OTHER"} require.NoError(t, fixture.database.Create(&otherProject).Error) unowned := performInboxJSON(t, router, http.MethodPost, "/api/v1/projects/"+otherProject.Identity+"/inbox", gin.H{ "sourceType": "text", "body": "not mine", }) require.Equal(t, http.StatusNotFound, unowned.Code) var count int64 require.NoError(t, fixture.database.Model(&models.SenlinAgentInboxItem{}).Count(&count).Error) require.Zero(t, count) } func TestAnalyzeUsesInboxIdentityAndCreatesNoFormalObjects(t *testing.T) { fixture := newInboxTestFixture(t) item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID) router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{ {Kind: "task", Title: "跟进报价", Body: "联系客户确认报价"}, }}) response := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil) require.Equal(t, http.StatusOK, response.Code) analysis := decodeInboxAnalysis(t, response) require.Len(t, analysis.Suggestions, 1) require.Equal(t, "task", analysis.Suggestions[0].Kind) require.Equal(t, "跟进报价", analysis.Suggestions[0].Title) requireUUIDv7(t, analysis.Suggestions[0].ID) requireFormalObjectCounts(t, fixture.database, 0, 0, 0) var reloaded models.SenlinAgentInboxItem require.NoError(t, fixture.database.First(&reloaded, item.ID).Error) require.Equal(t, "open", reloaded.Status) } func TestConfirmCreatesOnlySelectedSavedSuggestionsWithInboxIdentity(t *testing.T) { fixture := newInboxTestFixture(t) item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID) router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{ {Kind: "task", Title: "跟进报价", Body: "联系客户确认报价"}, {Kind: "note", Title: "会议纪要", Body: "保留讨论结论"}, {Kind: "source", Title: "背景资料", Body: "这是一段收集内容,不是上传文件"}, {Kind: "task", Title: "不创建的任务", Body: "未被勾选"}, }}) analysisResponse := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil) require.Equal(t, http.StatusOK, analysisResponse.Code) analysis := decodeInboxAnalysis(t, analysisResponse) require.Len(t, analysis.Suggestions, 4) selectedIDs := []string{analysis.Suggestions[0].ID, analysis.Suggestions[1].ID, analysis.Suggestions[2].ID} response := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{ "suggestionIds": selectedIDs, }) require.Equal(t, http.StatusOK, response.Code) var result struct { CreatedCount int `json:"createdCount"` } require.NoError(t, json.Unmarshal(response.Body.Bytes(), &result)) require.Equal(t, 3, result.CreatedCount) requireFormalObjectCounts(t, fixture.database, 1, 1, 1) var task models.SenlinAgentTask require.NoError(t, fixture.database.First(&task).Error) require.Equal(t, "跟进报价", task.Title) require.Equal(t, fixture.project.ID, task.ProjectID) require.Equal(t, fixture.owner.ID, task.CreatedBy) requireSourceInboxIdentity(t, item, task.SourceInboxItemID, task.SourceInboxItemIdentity) var note models.SenlinAgentNote require.NoError(t, fixture.database.First(¬e).Error) require.Equal(t, "会议纪要", note.Title) require.Equal(t, fixture.project.ID, note.ProjectID) require.Equal(t, fixture.owner.ID, note.CreatedBy) requireSourceInboxIdentity(t, item, note.SourceInboxItemID, note.SourceInboxItemIdentity) var source models.SenlinAgentSource require.NoError(t, fixture.database.First(&source).Error) require.Equal(t, "背景资料", source.Title) require.Equal(t, "text", source.Kind) require.Empty(t, source.FilePath) require.Equal(t, "这是一段收集内容,不是上传文件", source.ContentText) require.Equal(t, fixture.project.ID, source.ProjectID) require.Equal(t, fixture.owner.ID, source.CreatedBy) requireSourceInboxIdentity(t, item, source.SourceInboxItemID, source.SourceInboxItemIdentity) var skipped int64 require.NoError(t, fixture.database.Model(&models.SenlinAgentTask{}).Where("title = ?", "不创建的任务").Count(&skipped).Error) require.Zero(t, skipped) var reloaded models.SenlinAgentInboxItem require.NoError(t, fixture.database.First(&reloaded, item.ID).Error) require.Equal(t, "processed", reloaded.Status) } func TestConfirmRejectsClientForgedSuggestionsAndUnknownSuggestionIdentities(t *testing.T) { fixture := newInboxTestFixture(t) item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID) router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{ {Kind: "task", Title: "服务端建议", Body: "只能创建这个建议"}, }}) analysisResponse := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil) require.Equal(t, http.StatusOK, analysisResponse.Code) forgedContent := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{ "suggestions": []gin.H{{"kind": "task", "title": "客户端伪造任务", "body": "不可信内容"}}, }) require.Equal(t, http.StatusBadRequest, forgedContent.Code) unknownIdentity, err := uuid.NewV7() require.NoError(t, err) unknownSuggestion := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{ "suggestionIds": []string{unknownIdentity.String()}, }) require.Equal(t, http.StatusBadRequest, unknownSuggestion.Code) requireFormalObjectCounts(t, fixture.database, 0, 0, 0) } func TestAnalyzeAndConfirmHideAnotherUsersInboxItem(t *testing.T) { fixture := newInboxTestFixture(t) item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID) ownerRouter := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{ {Kind: "task", Title: "私有建议", Body: "不得越权确认"}, }}) analysis := decodeInboxAnalysis(t, performInboxJSON(t, ownerRouter, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil)) require.Len(t, analysis.Suggestions, 1) otherRouter := fixture.router(fixture.other.ID, StaticAnalyzer{}) unauthorizedAnalyze := performInboxJSON(t, otherRouter, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil) require.Equal(t, http.StatusNotFound, unauthorizedAnalyze.Code) unauthorizedConfirm := performInboxJSON(t, otherRouter, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{ "suggestionIds": []string{analysis.Suggestions[0].ID}, }) require.Equal(t, http.StatusNotFound, unauthorizedConfirm.Code) requireFormalObjectCounts(t, fixture.database, 0, 0, 0) } func TestConfirmIsTransactionalWhenASelectedWriteFails(t *testing.T) { fixture := newInboxTestFixture(t) item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID) router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{ {Kind: "task", Title: "事务任务", Body: "必须随失败回滚"}, {Kind: "source", Title: "失败资料", Body: "模拟持久化失败"}, }}) analysis := decodeInboxAnalysis(t, performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil)) require.Len(t, analysis.Suggestions, 2) const callbackName = "test:fail_inbox_source_create" require.NoError(t, fixture.database.Callback().Create().Before("gorm:create").Register(callbackName, func(tx *gorm.DB) { if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "SenlinAgentSource" { tx.AddError(errors.New("simulated source write failure")) } })) t.Cleanup(func() { _ = fixture.database.Callback().Create().Remove(callbackName) }) response := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{ "suggestionIds": []string{analysis.Suggestions[0].ID, analysis.Suggestions[1].ID}, }) require.Equal(t, http.StatusInternalServerError, response.Code) requireFormalObjectCounts(t, fixture.database, 0, 0, 0) var reloaded models.SenlinAgentInboxItem require.NoError(t, fixture.database.First(&reloaded, item.ID).Error) require.Equal(t, "open", reloaded.Status) } func TestRepeatedConfirmReturnsPersistedResultAfterRelatedObjectsChange(t *testing.T) { fixture := newInboxTestFixture(t) item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID) router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{ {Kind: "task", Title: "只创建一次", Body: "重复确认不能复制"}, {Kind: "note", Title: "确认结果", Body: "原始确认创建两个对象"}, {Kind: "source", Title: "不能追加创建", Body: "已确认后忽略不同建议"}, }}) analysis := decodeInboxAnalysis(t, performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil)) body := gin.H{"suggestionIds": []string{analysis.Suggestions[0].ID, analysis.Suggestions[1].ID}} first := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", body) require.Equal(t, http.StatusOK, first.Code) requireConfirmCreatedCount(t, first, 2) requireFormalObjectCounts(t, fixture.database, 1, 1, 0) sourceInboxItemID := item.ID extraSource := models.SenlinAgentSource{ ProjectID: item.ProjectID, CreatedBy: fixture.owner.ID, SourceInboxItemID: &sourceInboxItemID, Kind: "text", Title: "后续关联资料", ContentText: "不得改变历史确认结果", } require.NoError(t, fixture.database.Create(&extraSource).Error) second := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", body) require.Equal(t, http.StatusOK, second.Code, second.Body.String()) requireConfirmCreatedCount(t, second, 2) var createdTask models.SenlinAgentTask require.NoError(t, fixture.database.Where("source_inbox_item_id = ?", item.ID).First(&createdTask).Error) require.NoError(t, fixture.database.Delete(&createdTask).Error) require.NoError(t, fixture.database.Delete(&extraSource).Error) differentSelection := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{ "suggestionIds": []string{analysis.Suggestions[2].ID}, }) require.Equal(t, http.StatusOK, differentSelection.Code, differentSelection.Body.String()) requireConfirmCreatedCount(t, differentSelection, 2) requireFormalObjectCounts(t, fixture.database, 0, 1, 0) } func (fixture inboxTestFixture) router(userID uint, analyzer Analyzer) http.Handler { return httpx.NewProtectedRouter( config.Config{Env: "test"}, func(string) (uint, error) { return userID, nil }, NewHandler(NewService(analyzer)), ) } func (fixture inboxTestFixture) createInbox(t *testing.T, userID, projectID uint) models.SenlinAgentInboxItem { t.Helper() item := models.SenlinAgentInboxItem{ ProjectID: projectID, CreatedBy: userID, SourceType: "text", Title: "待整理", Body: "需要整理的原始内容", Status: "open", } require.NoError(t, fixture.database.Create(&item).Error) return item } func newInboxTestFixture(t *testing.T) inboxTestFixture { t.Helper() database := newTestDB(t) owner := models.SenlinAgentUser{Email: t.Name() + "-owner@example.com", DisplayName: "Owner", PasswordHash: "hash"} other := models.SenlinAgentUser{Email: t.Name() + "-other@example.com", DisplayName: "Other", PasswordHash: "hash"} require.NoError(t, database.Create(&owner).Error) require.NoError(t, database.Create(&other).Error) project := models.SenlinAgentProject{OwnerID: owner.ID, Name: "Inbox Project", Identifier: "INBOX"} require.NoError(t, database.Create(&project).Error) return inboxTestFixture{database: database, owner: owner, other: other, project: project} } func performInboxJSON(t *testing.T, router http.Handler, method, path string, payload any) *httptest.ResponseRecorder { t.Helper() var body bytes.Buffer if payload != nil { require.NoError(t, json.NewEncoder(&body).Encode(payload)) } request := httptest.NewRequest(method, path, &body) request.Header.Set("Authorization", "Bearer test-token") if payload != nil { request.Header.Set("Content-Type", "application/json") } response := httptest.NewRecorder() router.ServeHTTP(response, request) return response } func decodeInboxAnalysis(t *testing.T, response *httptest.ResponseRecorder) inboxAnalyzeResponse { t.Helper() require.Equal(t, http.StatusOK, response.Code, response.Body.String()) var payload inboxAnalyzeResponse require.NoError(t, json.Unmarshal(response.Body.Bytes(), &payload)) return payload } func requireFormalObjectCounts(t *testing.T, database *gorm.DB, tasks, notes, sources int64) { t.Helper() for _, check := range []struct { model any want int64 }{ {model: &models.SenlinAgentTask{}, want: tasks}, {model: &models.SenlinAgentNote{}, want: notes}, {model: &models.SenlinAgentSource{}, want: sources}, } { var count int64 require.NoError(t, database.Model(check.model).Count(&count).Error) require.Equal(t, check.want, count) } } func requireConfirmCreatedCount(t *testing.T, response *httptest.ResponseRecorder, want int) { t.Helper() var result ConfirmResult require.NoError(t, json.Unmarshal(response.Body.Bytes(), &result)) require.Equal(t, want, result.CreatedCount) } func requireSourceInboxIdentity(t *testing.T, item models.SenlinAgentInboxItem, sourceID *uint, sourceIdentity *string) { t.Helper() require.NotNil(t, sourceID) require.Equal(t, item.ID, *sourceID) require.NotNil(t, sourceIdentity) require.Equal(t, item.Identity, *sourceIdentity) } func requireUUIDv7(t *testing.T, value string) { t.Helper() parsed, err := uuid.Parse(value) require.NoError(t, err) require.Equal(t, uuid.Version(7), parsed.Version()) require.Equal(t, uuid.RFC4122, parsed.Variant()) } func inboxMapKeys(value map[string]any) []string { keys := make([]string, 0, len(value)) for key := range value { keys = append(keys, key) } return keys } func newTestDB(t *testing.T) *gorm.DB { t.Helper() database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) require.NoError(t, err) require.NoError(t, models.AutoMigrate(database)) models.DBService = database return database }