diff --git a/internal/ai/runtime/executor/answerability_gate_test.go b/internal/ai/runtime/executor/answerability_gate_test.go deleted file mode 100644 index 4df42e0..0000000 --- a/internal/ai/runtime/executor/answerability_gate_test.go +++ /dev/null @@ -1,489 +0,0 @@ -package executor - -import ( - "context" - "encoding/json" - "errors" - "strings" - "testing" - - "cs-agent/internal/ai/rag" - "cs-agent/internal/ai/runtime/internal/impl/callbacks" - "cs-agent/internal/ai/runtime/internal/impl/retrievers" - "cs-agent/internal/models" - - "github.com/cloudwego/eino/components/model" - "github.com/cloudwego/eino/schema" -) - -func TestParseAnswerabilityDecisionRejectsMalformedJSON(t *testing.T) { - _, err := parseAnswerabilityDecision(`{"answerable": true`) - - if err == nil { - t.Fatal("expected malformed JSON to be rejected") - } -} - -func TestParseAnswerabilityDecisionRejectsAnswerableWithoutSupportingChunkIDs(t *testing.T) { - _, err := parseAnswerabilityDecision(`{"answerable": true, "reason": "directly supported"}`) - - if err == nil { - t.Fatal("expected answerable decision without supporting chunks to be rejected") - } -} - -func TestParseAnswerabilityDecisionAcceptsAnswerableWithSupportingChunkIDs(t *testing.T) { - got, err := parseAnswerabilityDecision("```json\n{\"answerable\": true, \"reason\": \"directly supported\", \"supportingChunkIds\": [\" chunk-1 \", \"chunk-2\"]}\n```") - if err != nil { - t.Fatalf("parse decision failed: %v", err) - } - - if !got.Answerable { - t.Fatal("expected answerable decision") - } - if got.SupportingChunkIDs[0] != "chunk-1" || got.SupportingChunkIDs[1] != "chunk-2" { - t.Fatalf("unexpected supporting chunks: %#v", got.SupportingChunkIDs) - } -} - -func TestKnowledgeAnswerabilityGateEvaluateFallsBackOnRetrieverError(t *testing.T) { - collector := callbacks.NewRuntimeTraceCollector() - gate := newTestKnowledgeAnswerabilityGate(&fakeKnowledgeContextRetriever{ - knowledgeBaseIDs: []int64{1}, - err: errors.New("vector store unavailable"), - }, nil) - - state, err := gate.Evaluate(context.Background(), answerabilityGateInput{ - Request: newAnswerabilityGateRunInput("是否支持退款?", "1"), - Summary: &RunResult{}, - Collector: collector, - }) - if err != nil { - t.Fatalf("Evaluate returned error: %v", err) - } - - if !strings.Contains(state.FallbackReply, "建议你联系人工客服进一步确认。") { - t.Fatalf("expected human-support fallback, got %q", state.FallbackReply) - } - if collector.Data.Answerability.Status != answerabilityStatusUnanswerable { - t.Fatalf("unexpected answerability status: %q", collector.Data.Answerability.Status) - } - if collector.Data.Answerability.Reason != "knowledge retrieval failed" { - t.Fatalf("unexpected reason: %q", collector.Data.Answerability.Reason) - } -} - -func TestKnowledgeAnswerabilityGateEvaluateSkipsWhenNoKnowledgeConfigured(t *testing.T) { - collector := callbacks.NewRuntimeTraceCollector() - gate := newTestKnowledgeAnswerabilityGate(&fakeKnowledgeContextRetriever{}, nil) - - state, err := gate.Evaluate(context.Background(), answerabilityGateInput{ - Request: newAnswerabilityGateRunInput("是否支持退款?", ""), - Collector: collector, - }) - if err != nil { - t.Fatalf("Evaluate returned error: %v", err) - } - - if !state.SkipGate { - t.Fatal("expected gate to skip without knowledge") - } - if state.FallbackReply != "" { - t.Fatalf("expected no fallback when gate skips, got %q", state.FallbackReply) - } - if collector.Data.Answerability.Status != answerabilityStatusSkipped { - t.Fatalf("unexpected answerability status: %q", collector.Data.Answerability.Status) - } -} - -func TestKnowledgeAnswerabilityGateEvaluateFallsBackWhenConfiguredRetrieverUnavailable(t *testing.T) { - collector := callbacks.NewRuntimeTraceCollector() - gate := newTestKnowledgeAnswerabilityGate(nil, nil) - - state, err := gate.Evaluate(context.Background(), answerabilityGateInput{ - Request: newAnswerabilityGateRunInput("是否支持退款?", "1"), - Collector: collector, - }) - if err != nil { - t.Fatalf("Evaluate returned error: %v", err) - } - - if state.SkipGate { - t.Fatal("expected configured knowledge to fail closed, not skip") - } - if !strings.Contains(state.FallbackReply, "建议你联系人工客服进一步确认。") { - t.Fatalf("expected human-support fallback, got %q", state.FallbackReply) - } - if collector.Data.Answerability.Status != answerabilityStatusUnanswerable { - t.Fatalf("unexpected answerability status: %q", collector.Data.Answerability.Status) - } - if collector.Data.Answerability.Reason != "knowledge retriever unavailable" { - t.Fatalf("unexpected reason: %q", collector.Data.Answerability.Reason) - } -} - -func TestKnowledgeAnswerabilityGateEvaluateFallsBackOnGrayZoneUnanswerableDecision(t *testing.T) { - collector := callbacks.NewRuntimeTraceCollector() - gate := newTestKnowledgeAnswerabilityGate(newAnswerabilityRetrieverWithHit(), &fakeAnswerabilityChatModel{ - response: `{"answerable": false, "reason": "retrieved snippets mention refunds but not the requested condition", "missingInfo": ["refund condition"]}`, - }) - - state, err := gate.Evaluate(context.Background(), answerabilityGateInput{ - Request: newAnswerabilityGateRunInput("满足什么条件可以退款?", "1"), - Collector: collector, - }) - if err != nil { - t.Fatalf("Evaluate returned error: %v", err) - } - - if !strings.Contains(state.FallbackReply, "建议你联系人工客服进一步确认。") { - t.Fatalf("expected human-support fallback, got %q", state.FallbackReply) - } - if collector.Data.Answerability.Status != answerabilityStatusUnanswerable { - t.Fatalf("unexpected answerability status: %q", collector.Data.Answerability.Status) - } - if collector.Data.Answerability.MissingInfo[0] != "refund condition" { - t.Fatalf("unexpected missing info: %#v", collector.Data.Answerability.MissingInfo) - } -} - -func TestKnowledgeAnswerabilityGateEvaluateAllowsAnswerableDecisionAndProducesKnowledgeInstruction(t *testing.T) { - collector := callbacks.NewRuntimeTraceCollector() - summary := &RunResult{} - chatModel := &fakeAnswerabilityChatModel{ - response: `{"answerable": true, "reason": "refund condition is directly supported", "supportingChunkIds": ["101"]}`, - } - gate := newTestKnowledgeAnswerabilityGate(newAnswerabilityRetrieverWithHit(), chatModel) - - state, err := gate.Evaluate(context.Background(), answerabilityGateInput{ - Request: newAnswerabilityGateRunInput("满足什么条件可以退款?", "1"), - Summary: summary, - Collector: collector, - }) - if err != nil { - t.Fatalf("Evaluate returned error: %v", err) - } - - if state.FallbackReply != "" { - t.Fatalf("expected answerable gate to allow, got fallback %q", state.FallbackReply) - } - if len(state.Decision.Instructions) != 1 { - t.Fatalf("expected one knowledge instruction, got %d", len(state.Decision.Instructions)) - } - if !strings.Contains(state.Decision.Instructions[0].Content, "知识库回答约束") { - t.Fatalf("unexpected instruction: %q", state.Decision.Instructions[0].Content) - } - if summary.RetrieverCount != 1 { - t.Fatalf("expected retriever count 1, got %d", summary.RetrieverCount) - } - if collector.Data.Answerability.Status != answerabilityStatusAnswerable { - t.Fatalf("unexpected answerability status: %q", collector.Data.Answerability.Status) - } - if collector.Data.Answerability.SupportingChunkIDs[0] != "101" { - t.Fatalf("unexpected supporting chunks: %#v", collector.Data.Answerability.SupportingChunkIDs) - } - if len(chatModel.input) == 0 || !strings.Contains(chatModel.input[len(chatModel.input)-1].Content, "chunkId: 101") { - t.Fatalf("expected grader prompt to include chunk id, got %#v", chatModel.input) - } -} - -func TestKnowledgeAnswerabilityGateEvaluateFallsBackWhenSupportingChunkIDsAreInvalid(t *testing.T) { - collector := callbacks.NewRuntimeTraceCollector() - chatModel := &fakeAnswerabilityChatModel{ - response: `{"answerable": true, "reason": "refund condition is directly supported", "supportingChunkIds": ["not-real"]}`, - } - gate := newTestKnowledgeAnswerabilityGate(newAnswerabilityRetrieverWithHit(), chatModel) - - state, err := gate.Evaluate(context.Background(), answerabilityGateInput{ - Request: newAnswerabilityGateRunInput("满足什么条件可以退款?", "1"), - Collector: collector, - }) - if err != nil { - t.Fatalf("Evaluate returned error: %v", err) - } - - if !strings.Contains(state.FallbackReply, "建议你联系人工客服进一步确认。") { - t.Fatalf("expected human-support fallback, got %q", state.FallbackReply) - } - if len(state.Decision.Instructions) != 0 { - t.Fatalf("expected no knowledge instruction, got %d", len(state.Decision.Instructions)) - } - if collector.Data.Answerability.Status != answerabilityStatusUnanswerable { - t.Fatalf("unexpected answerability status: %q", collector.Data.Answerability.Status) - } - if collector.Data.Answerability.Reason != "answerability supporting chunks invalid" { - t.Fatalf("unexpected reason: %q", collector.Data.Answerability.Reason) - } - if strings.TrimSpace(collector.Data.Answerability.ErrorMessage) == "" { - t.Fatal("expected invalid supporting chunk error message") - } -} - -func TestValidateAnswerabilitySupportAcceptsSupportedIDForms(t *testing.T) { - result := &retrievers.KnowledgeRetrieveResult{ - ContextResults: []rag.RetrieveResult{ - {KnowledgeBaseID: 1, DocumentID: 10, FaqID: 20, ChunkID: 101}, - }, - Hits: []rag.RetrieveResult{ - {KnowledgeBaseID: 2, DocumentID: 30, ChunkID: 202}, - }, - } - supportedIDs := []string{ - "101", - "chunk:101", - "chunkId:101", - "chunk-101", - "kb:1:doc:10:chunk:101", - "kb:1:faq:20:chunk:101", - } - - for _, supportingChunkID := range supportedIDs { - t.Run(supportingChunkID, func(t *testing.T) { - err := validateAnswerabilitySupport(answerabilityDecision{ - Answerable: true, - SupportingChunkIDs: []string{supportingChunkID}, - }, result) - if err != nil { - t.Fatalf("expected %q to be accepted: %v", supportingChunkID, err) - } - }) - } -} - -func TestValidateAnswerabilitySupportUsesContextResultsBeforeHits(t *testing.T) { - result := &retrievers.KnowledgeRetrieveResult{ - ContextResults: []rag.RetrieveResult{ - {KnowledgeBaseID: 1, DocumentID: 10, ChunkID: 101}, - }, - Hits: []rag.RetrieveResult{ - {KnowledgeBaseID: 2, DocumentID: 20, ChunkID: 202}, - }, - } - - err := validateAnswerabilitySupport(answerabilityDecision{ - Answerable: true, - SupportingChunkIDs: []string{"202"}, - }, result) - - if err == nil { - t.Fatal("expected hit outside context results to be rejected") - } -} - -func TestExecuteRunReturnsKnowledgeFallbackBeforeInvalidMCPToolConfig(t *testing.T) { - gate := newTestKnowledgeAnswerabilityGate(&fakeKnowledgeContextRetriever{ - knowledgeBaseIDs: []int64{1}, - err: errors.New("vector store unavailable"), - }, nil) - service := &Service{ - answerabilityGate: gate, - } - - result, err := service.ExecuteRun(context.Background(), RunInput{ - UserMessage: models.Message{Content: "是否支持退款?"}, - AIAgent: models.AIAgent{ - KnowledgeIDs: "1", - AllowedMCPTools: "{invalid", - }, - AIConfig: models.AIConfig{ModelName: "fake-model"}, - }) - if err != nil { - t.Fatalf("ExecuteRun returned error before fallback: %v", err) - } - - if result.Status != "completed" { - t.Fatalf("expected completed fallback result, got %q", result.Status) - } - if !strings.Contains(result.ReplyText, "建议你联系人工客服进一步确认。") { - t.Fatalf("expected human-support fallback, got %q", result.ReplyText) - } - var trace callbacks.RuntimeTraceData - if err := json.Unmarshal([]byte(result.TraceData), &trace); err != nil { - t.Fatalf("unmarshal trace data: %v", err) - } - if trace.Answerability.Status != answerabilityStatusUnanswerable { - t.Fatalf("unexpected answerability status: %q", trace.Answerability.Status) - } - if trace.Model.Name != "fake-model" { - t.Fatalf("expected model name to be recorded before fallback, got %q", trace.Model.Name) - } -} - -func TestBuildRunMessagesReturnsFallbackWhenGateRejectsGrayZoneQuestion(t *testing.T) { - summary := &RunResult{} - question := "满足什么条件可以退款?" - gate := newTestKnowledgeAnswerabilityGate(newAnswerabilityRetrieverWithHit(), &fakeAnswerabilityChatModel{ - response: `{"answerable": false, "reason": "retrieved snippets mention refunds but not the requested condition", "missingInfo": ["refund condition"]}`, - }) - - messages := buildRunMessages(context.Background(), newAnswerabilityGateRunInput(question, "1"), summary, nil, gate) - - if !strings.Contains(summary.ReplyText, "建议你联系人工客服进一步确认。") { - t.Fatalf("expected human-support fallback, got %q", summary.ReplyText) - } - if messagesContainContent(messages, question) { - t.Fatalf("expected returned messages to omit current user message, got %#v", messages) - } -} - -func TestBuildRunMessagesInjectsKnowledgeWhenGateAllows(t *testing.T) { - summary := &RunResult{} - question := "满足什么条件可以退款?" - gate := newTestKnowledgeAnswerabilityGate(newAnswerabilityRetrieverWithHit(), &fakeAnswerabilityChatModel{ - response: `{"answerable": true, "reason": "refund condition is directly supported", "supportingChunkIds": ["101"]}`, - }) - - messages := buildRunMessages(context.Background(), newAnswerabilityGateRunInput(question, "1"), summary, nil, gate) - - if summary.ReplyText != "" { - t.Fatalf("expected no fallback, got %q", summary.ReplyText) - } - if !messagesContainContent(messages, "知识库回答约束") { - t.Fatalf("expected knowledge instruction in messages, got %#v", messages) - } - if !messagesContainContent(messages, "购买后七天内且未使用可以退款。") { - t.Fatalf("expected knowledge context in messages, got %#v", messages) - } - if !messagesContainContent(messages, question) { - t.Fatalf("expected current user message in messages, got %#v", messages) - } -} - -func TestBuildRunMessagesSkipsAnswerabilityGateForExplicitHandoffIntent(t *testing.T) { - summary := &RunResult{} - retriever := &fakeKnowledgeContextRetriever{ - knowledgeBaseIDs: []int64{1}, - err: errors.New("retriever should not be called for handoff intent"), - } - gate := newTestKnowledgeAnswerabilityGate(retriever, nil) - - messages := buildRunMessages(context.Background(), newAnswerabilityGateRunInput("我要转人工", "1"), summary, nil, gate) - - if summary.ReplyText != "" { - t.Fatalf("expected handoff intent to continue to agent/tool routing, got fallback %q", summary.ReplyText) - } - if retriever.lastQuery != "" { - t.Fatalf("expected retriever to be skipped for handoff intent, got query %q", retriever.lastQuery) - } - if !messagesContainContent(messages, "我要转人工") { - t.Fatalf("expected current user message in messages, got %#v", messages) - } -} - -func TestBuildRunMessagesSkipsAnswerabilityGateForExplicitTicketIntent(t *testing.T) { - summary := &RunResult{} - retriever := &fakeKnowledgeContextRetriever{ - knowledgeBaseIDs: []int64{1}, - err: errors.New("retriever should not be called for ticket intent"), - } - gate := newTestKnowledgeAnswerabilityGate(retriever, nil) - - messages := buildRunMessages(context.Background(), newAnswerabilityGateRunInput("帮我创建一个工单", "1"), summary, nil, gate) - - if summary.ReplyText != "" { - t.Fatalf("expected ticket intent to continue to agent/tool routing, got fallback %q", summary.ReplyText) - } - if retriever.lastQuery != "" { - t.Fatalf("expected retriever to be skipped for ticket intent, got query %q", retriever.lastQuery) - } - if !messagesContainContent(messages, "帮我创建一个工单") { - t.Fatalf("expected current user message in messages, got %#v", messages) - } -} - -func TestRuntimeActionIntentDoesNotMatchKnowledgeQuestions(t *testing.T) { - cases := []string{ - "人工客服服务时间是什么?", - "工单状态怎么查询?", - "需要查询工单状态怎么操作?", - } - - for _, tc := range cases { - t.Run(tc, func(t *testing.T) { - if isRuntimeActionIntent(tc) { - t.Fatalf("expected %q to stay in knowledge answerability flow", tc) - } - }) - } -} - -func newTestKnowledgeAnswerabilityGate(retriever knowledgeContextRetriever, chatModel model.BaseChatModel) *KnowledgeAnswerabilityGate { - return &KnowledgeAnswerabilityGate{ - newRetriever: func(aiAgent models.AIAgent) knowledgeContextRetriever { - return retriever - }, - newChatModel: func(ctx context.Context, aiConfig models.AIConfig) (model.BaseChatModel, error) { - return chatModel, nil - }, - } -} - -func newAnswerabilityGateRunInput(question string, knowledgeIDs string) RunInput { - return RunInput{ - UserMessage: models.Message{Content: question}, - AIAgent: models.AIAgent{ - KnowledgeIDs: knowledgeIDs, - }, - } -} - -func newAnswerabilityRetrieverWithHit() *fakeKnowledgeContextRetriever { - return &fakeKnowledgeContextRetriever{ - knowledgeBaseIDs: []int64{1}, - result: &retrievers.KnowledgeRetrieveResult{ - KnowledgeBaseIDs: []int64{1}, - Hits: []rag.RetrieveResult{ - {KnowledgeBaseID: 1, DocumentID: 10, ChunkID: 101, Score: 0.93, Content: "购买后七天内且未使用可以退款。"}, - }, - ContextResults: []rag.RetrieveResult{ - {KnowledgeBaseID: 1, DocumentID: 10, ChunkID: 101, Score: 0.93, Content: "购买后七天内且未使用可以退款。"}, - }, - ContextText: "购买后七天内且未使用可以退款。", - }, - } -} - -type fakeKnowledgeContextRetriever struct { - knowledgeBaseIDs []int64 - result *retrievers.KnowledgeRetrieveResult - err error - lastOptions retrievers.KnowledgeRetrieveOptions - lastQuery string -} - -func (f *fakeKnowledgeContextRetriever) KnowledgeBaseIDs() []int64 { - return append([]int64(nil), f.knowledgeBaseIDs...) -} - -func (f *fakeKnowledgeContextRetriever) RetrieveContextByOptions(ctx context.Context, opts retrievers.KnowledgeRetrieveOptions, query string) (*retrievers.KnowledgeRetrieveResult, error) { - f.lastOptions = opts - f.lastQuery = query - return f.result, f.err -} - -type fakeAnswerabilityChatModel struct { - response string - err error - input []*schema.Message -} - -func (f *fakeAnswerabilityChatModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) { - f.input = input - if f.err != nil { - return nil, f.err - } - return schema.AssistantMessage(f.response, nil), nil -} - -func (f *fakeAnswerabilityChatModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) { - return nil, errors.New("stream is not implemented in fakeAnswerabilityChatModel") -} - -func messagesContainContent(messages []*schema.Message, text string) bool { - for _, message := range messages { - if message != nil && strings.Contains(message.Content, text) { - return true - } - } - return false -} diff --git a/internal/migration/000008_sync_lightweight_ticket_permissions.go b/internal/migration/000008_sync_lightweight_ticket_permissions.go deleted file mode 100644 index 91ecc9a..0000000 --- a/internal/migration/000008_sync_lightweight_ticket_permissions.go +++ /dev/null @@ -1,104 +0,0 @@ -package migration - -import ( - "cs-agent/internal/models" - - "github.com/mlogclub/simple/sqls" - "gorm.io/gorm" -) - -func init() { - register(8, "sync lightweight ticket permissions and reset ticket data", func() error { - return sqls.WithTransaction(func(ctx *sqls.TxContext) error { - if err := resetLightweightTicketData(ctx.Tx); err != nil { - return err - } - if err := deleteObsoleteTicketPermissions(ctx.Tx); err != nil { - return err - } - - permissions, err := ensurePermissions(ctx.Tx) - if err != nil { - return err - } - - roles, err := ensureRoles(ctx.Tx) - if err != nil { - return err - } - - return ensureRolePermissions(ctx.Tx, roles, permissions) - }) - }) -} - -func deleteObsoleteTicketPermissions(tx *gorm.DB) error { - codes := obsoleteTicketPermissionCodes() - if len(codes) == 0 { - return nil - } - permissionIDs := tx.Model(&models.Permission{}).Select("id").Where("code IN ?", codes) - if err := tx.Where("permission_id IN (?)", permissionIDs).Delete(&models.RolePermission{}).Error; err != nil { - return err - } - permissionIDs = tx.Model(&models.Permission{}).Select("id").Where("code IN ?", codes) - if err := tx.Where("permission_id IN (?)", permissionIDs).Delete(&models.UserPermission{}).Error; err != nil { - return err - } - return tx.Where("code IN ?", codes).Delete(&models.Permission{}).Error -} - -func obsoleteTicketPermissionCodes() []string { - return []string{ - "ticket.reply", - "ticket.close", - "ticket.reopen", - "ticketResolutionCode.view", - "ticketResolutionCode.create", - "ticketResolutionCode.update", - "ticketResolutionCode.delete", - "ticketPriorityConfig.view", - "ticketPriorityConfig.create", - "ticketPriorityConfig.update", - "ticketPriorityConfig.delete", - } -} - -func resetLightweightTicketData(tx *gorm.DB) error { - for _, table := range lightweightTicketResetTables(tx) { - if !tx.Migrator().HasTable(table) { - continue - } - if err := tx.Exec("DELETE FROM " + table).Error; err != nil { - return err - } - } - return nil -} - -func lightweightTicketResetTables(tx *gorm.DB) []string { - return []string{ - "t_ticket_sla_record", - "t_ticket_resolution_code", - "t_ticket_priority_config", - "t_ticket_watcher", - "t_ticket_collaborator", - "t_ticket_mention", - "t_ticket_event_log", - "t_ticket_relation", - "t_ticket_comment", - tableName(tx, &models.TicketProgress{}, "t_ticket_progress"), - tableName(tx, &models.TicketTag{}, "t_ticket_tag"), - tableName(tx, &models.Ticket{}, "t_ticket"), - tableName(tx, &models.TicketNoSequence{}, "t_ticket_no_sequence"), - tableName(tx, &models.TicketView{}, "t_ticket_view"), - } -} - -func tableName(tx *gorm.DB, model any, fallback string) string { - stmt := &gorm.Statement{DB: tx} - if err := stmt.Parse(model); err != nil { - return fallback - } - return stmt.Schema.Table -} diff --git a/internal/migration/notification_permissions_test.go b/internal/migration/notification_permissions_test.go deleted file mode 100644 index f44ebb5..0000000 --- a/internal/migration/notification_permissions_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package migration - -import ( - "testing" - "time" - - "cs-agent/internal/models" - - "github.com/glebarez/sqlite" - "gorm.io/gorm" - "gorm.io/gorm/schema" -) - -func TestNotificationPermissionMigrationRegistered(t *testing.T) { - migration, ok := migrationFuncs[7] - if !ok { - t.Fatalf("expected migration version 7 to be registered") - } - if migration.Remark != "sync notification permissions" { - t.Fatalf("unexpected migration remark: %q", migration.Remark) - } -} - -func TestLightweightTicketPermissionMigrationRegistered(t *testing.T) { - migration, ok := migrationFuncs[8] - if !ok { - t.Fatalf("expected migration version 8 to be registered") - } - if migration.Remark != "sync lightweight ticket permissions and reset ticket data" { - t.Fatalf("unexpected migration remark: %q", migration.Remark) - } -} - -func TestLightweightTicketMigrationResetDeletesTicketData(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ - NamingStrategy: schema.NamingStrategy{ - TablePrefix: "t_", - SingularTable: true, - }, - }) - if err != nil { - t.Fatalf("open sqlite db: %v", err) - } - sqlDB, err := db.DB() - if err != nil { - t.Fatalf("get sqlite db: %v", err) - } - t.Cleanup(func() { - _ = sqlDB.Close() - }) - - if err := db.AutoMigrate(&models.Ticket{}, &models.TicketTag{}, &models.TicketProgress{}, &models.TicketNoSequence{}); err != nil { - t.Fatalf("AutoMigrate() error = %v", err) - } - - now := time.Now() - ticket := &models.Ticket{ - TicketNo: "TK2026050200001", - Title: "legacy ticket", - Description: "legacy ticket description", - AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, - } - if err := db.Create(ticket).Error; err != nil { - t.Fatalf("create ticket error = %v", err) - } - if err := db.Create(&models.TicketTag{TicketID: ticket.ID, TagID: 1, AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}}).Error; err != nil { - t.Fatalf("create ticket tag error = %v", err) - } - if err := db.Create(&models.TicketProgress{TicketID: ticket.ID, Content: "legacy progress", CreatedAt: now}).Error; err != nil { - t.Fatalf("create ticket progress error = %v", err) - } - if err := db.Create(&models.TicketNoSequence{DateKey: "20260502", NextSeq: 2, CreatedAt: now, UpdatedAt: now}).Error; err != nil { - t.Fatalf("create ticket no sequence error = %v", err) - } - - if err := resetLightweightTicketData(db); err != nil { - t.Fatalf("resetLightweightTicketData() error = %v", err) - } - - assertTableCount(t, db, &models.Ticket{}, 0) - assertTableCount(t, db, &models.TicketTag{}, 0) - assertTableCount(t, db, &models.TicketProgress{}, 0) - assertTableCount(t, db, &models.TicketNoSequence{}, 0) -} - -func TestLightweightTicketMigrationDeletesObsoletePermissions(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ - NamingStrategy: schema.NamingStrategy{ - TablePrefix: "t_", - SingularTable: true, - }, - }) - if err != nil { - t.Fatalf("open sqlite db: %v", err) - } - sqlDB, err := db.DB() - if err != nil { - t.Fatalf("get sqlite db: %v", err) - } - t.Cleanup(func() { - _ = sqlDB.Close() - }) - - if err := db.AutoMigrate(&models.Permission{}, &models.RolePermission{}, &models.UserPermission{}); err != nil { - t.Fatalf("AutoMigrate() error = %v", err) - } - - now := time.Now() - legacyPermission := &models.Permission{ - Name: "回复工单", - Code: "ticket.reply", - Type: "api", - GroupName: "ticket", - AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, - } - currentPermission := &models.Permission{ - Name: "查看工单", - Code: "ticket.view", - Type: "api", - GroupName: "ticket", - AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, - } - if err := db.Create(legacyPermission).Error; err != nil { - t.Fatalf("create legacy permission error = %v", err) - } - if err := db.Create(currentPermission).Error; err != nil { - t.Fatalf("create current permission error = %v", err) - } - if err := db.Create(&models.RolePermission{RoleID: 1, PermissionID: legacyPermission.ID, AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}}).Error; err != nil { - t.Fatalf("create legacy role permission error = %v", err) - } - if err := db.Create(&models.UserPermission{UserID: 1, PermissionID: legacyPermission.ID, Effect: 1, AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}}).Error; err != nil { - t.Fatalf("create legacy user permission error = %v", err) - } - if err := db.Create(&models.RolePermission{RoleID: 1, PermissionID: currentPermission.ID, AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}}).Error; err != nil { - t.Fatalf("create current role permission error = %v", err) - } - - if err := deleteObsoleteTicketPermissions(db); err != nil { - t.Fatalf("deleteObsoleteTicketPermissions() error = %v", err) - } - - assertPermissionCodeCount(t, db, "ticket.reply", 0) - assertPermissionCodeCount(t, db, "ticket.view", 1) - assertPermissionRelationCount(t, db, &models.RolePermission{}, legacyPermission.ID, 0) - assertPermissionRelationCount(t, db, &models.UserPermission{}, legacyPermission.ID, 0) - assertPermissionRelationCount(t, db, &models.RolePermission{}, currentPermission.ID, 1) -} - -func assertTableCount(t *testing.T, db *gorm.DB, model any, expected int64) { - t.Helper() - - var count int64 - if err := db.Model(model).Count(&count).Error; err != nil { - t.Fatalf("count %T error = %v", model, err) - } - if count != expected { - t.Fatalf("expected %T count %d, got %d", model, expected, count) - } -} - -func assertPermissionCodeCount(t *testing.T, db *gorm.DB, code string, expected int64) { - t.Helper() - - var count int64 - if err := db.Model(&models.Permission{}).Where("code = ?", code).Count(&count).Error; err != nil { - t.Fatalf("count permission %s error = %v", code, err) - } - if count != expected { - t.Fatalf("expected permission %s count %d, got %d", code, expected, count) - } -} - -func assertPermissionRelationCount(t *testing.T, db *gorm.DB, model any, permissionID int64, expected int64) { - t.Helper() - - var count int64 - if err := db.Model(model).Where("permission_id = ?", permissionID).Count(&count).Error; err != nil { - t.Fatalf("count %T permission %d error = %v", model, permissionID, err) - } - if count != expected { - t.Fatalf("expected %T permission %d count %d, got %d", model, permissionID, expected, count) - } -} diff --git a/web/app/dashboard/tickets/_components/ticket-detail-dialog.tsx b/web/app/dashboard/tickets/_components/ticket-detail-dialog.tsx index a57113d..eab8adb 100644 --- a/web/app/dashboard/tickets/_components/ticket-detail-dialog.tsx +++ b/web/app/dashboard/tickets/_components/ticket-detail-dialog.tsx @@ -252,11 +252,11 @@ export function TicketDetailDialog({ } description={ ticket ? ( -