package taskcenter import ( "context" "strings" "testing" "time" "chatapp3-golang/internal/common" "chatapp3-golang/internal/config" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func newTestService(t *testing.T, java taskCenterJavaGateway) (*Service, *gorm.DB) { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := db.AutoMigrate( &model.TaskCenterConfig{}, &model.TaskCenterTaskConfig{}, &model.TaskCenterConditionJumpConfig{}, &model.TaskCenterUserProgress{}, &model.TaskCenterEvent{}, &model.TaskCenterEventDedup{}, &model.TaskCenterEventArchiveOutbox{}, &model.TaskCenterClaimRecord{}, ); err != nil { t.Fatalf("auto migrate: %v", err) } service := NewService(config.Config{ TaskCenter: config.TaskCenterConfig{ DefaultSysOrigin: "LIKEI", Timezone: "Asia/Riyadh", }, }, db, java) return service, db } func TestNormalizeTaskCenterArchiveBatchSize(t *testing.T) { tests := []struct { name string input int want int }{ {name: "default", input: 0, want: defaultTaskCenterArchiveBatchSize}, {name: "negative default", input: -1, want: defaultTaskCenterArchiveBatchSize}, {name: "keeps configured value", input: 200, want: 200}, {name: "caps large batch", input: 10000, want: maxTaskCenterArchiveBatchSize}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := normalizeTaskCenterArchiveBatchSize(tt.input); got != tt.want { t.Fatalf("normalizeTaskCenterArchiveBatchSize(%d) = %d, want %d", tt.input, got, tt.want) } }) } } func TestTaskCenterArchiveClaimLimitsReserveStaleRows(t *testing.T) { tests := []struct { name string batchSize int wantStale int wantRetryable int }{ {name: "single row", batchSize: 1, wantStale: 1, wantRetryable: 0}, {name: "small batch keeps one stale slot", batchSize: 5, wantStale: 1, wantRetryable: 4}, {name: "normal batch uses ten percent", batchSize: 500, wantStale: 50, wantRetryable: 450}, {name: "large batch caps stale slot", batchSize: 1000, wantStale: 100, wantRetryable: 900}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotStale, gotRetryable := taskCenterArchiveClaimLimits(tt.batchSize) if gotStale != tt.wantStale || gotRetryable != tt.wantRetryable { t.Fatalf("taskCenterArchiveClaimLimits(%d) = (%d, %d), want (%d, %d)", tt.batchSize, gotStale, gotRetryable, tt.wantStale, tt.wantRetryable) } }) } } func TestDailyTaskConfigEventListAndClaim(t *testing.T) { java := &taskCenterTestGateway{events: map[string]bool{}} service, db := newTestService(t, java) ctx := context.Background() configResp, err := service.SaveConfig(ctx, SaveConfigRequest{ SysOrigin: "likei", Enabled: true, Timezone: "Asia/Riyadh", TaskCategory: TaskCategoryDaily, Tasks: []SaveTaskConfigInput{ { TaskCode: "daily_mic_duration", Enabled: true, TaskName: "使用麦克风 %d 分钟。", TaskDesc: "进度 / 目标", ConditionType: "mic_duration_seconds", TargetValue: 60, TargetUnit: "second", RewardGold: 1100, JumpType: "ROOM_RANDOM_WITH_MIC", SortOrder: 10, }, }, }) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } if !configResp.Configured || !configResp.Enabled || len(configResp.Tasks) != 1 { t.Fatalf("SaveConfig() resp = %+v", configResp) } user := common.AuthUser{UserID: 1001, SysOrigin: "LIKEI"} tasks, err := service.ListTasks(ctx, user) if err != nil { t.Fatalf("ListTasks() error = %v", err) } if len(tasks) != 1 || tasks[0].TaskStatus != 0 || tasks[0].CompletedValue != 0 { t.Fatalf("initial tasks = %+v", tasks) } eventResp, err := service.ReceiveEvent(ctx, EventRequest{ SysOrigin: "LIKEI", EventID: "event-1", EventType: "MIC_DURATION_SECONDS", UserID: flexibleInt64(user.UserID), DeltaValue: 60, }) if err != nil { t.Fatalf("ReceiveEvent() error = %v", err) } if !eventResp.Processed || eventResp.Updated != 1 { t.Fatalf("ReceiveEvent() resp = %+v", eventResp) } duplicateResp, err := service.ReceiveEvent(ctx, EventRequest{ SysOrigin: "LIKEI", EventID: "event-1", EventType: "MIC_DURATION_SECONDS", UserID: flexibleInt64(user.UserID), DeltaValue: 60, }) if err != nil { t.Fatalf("ReceiveEvent() duplicate error = %v", err) } if duplicateResp.Processed || duplicateResp.Reason != "duplicate_event" { t.Fatalf("ReceiveEvent() duplicate resp = %+v", duplicateResp) } var dedupCount int64 if err := db.Model(&model.TaskCenterEventDedup{}). Where("sys_origin = ? AND event_id = ?", "LIKEI", "event-1"). Count(&dedupCount).Error; err != nil { t.Fatalf("count dedup: %v", err) } if dedupCount != 1 { t.Fatalf("dedup rows = %d, want 1", dedupCount) } var outboxCount int64 if err := db.Model(&model.TaskCenterEventArchiveOutbox{}). Where("sys_origin = ? AND event_id = ?", "LIKEI", "event-1"). Count(&outboxCount).Error; err != nil { t.Fatalf("count archive outbox: %v", err) } if outboxCount != 1 { t.Fatalf("archive outbox rows = %d, want 1", outboxCount) } var legacyEventCount int64 if err := db.Model(&model.TaskCenterEvent{}). Where("sys_origin = ? AND event_id = ?", "LIKEI", "event-1"). Count(&legacyEventCount).Error; err != nil { t.Fatalf("count legacy task event: %v", err) } if legacyEventCount != 0 { t.Fatalf("legacy task event rows = %d, want 0", legacyEventCount) } tasks, err = service.ListTasks(ctx, user) if err != nil { t.Fatalf("ListTasks() after event error = %v", err) } if len(tasks) != 1 || tasks[0].TaskStatus != 1 || tasks[0].CompletedValue != 60 { t.Fatalf("completed tasks = %+v", tasks) } ok, err := service.Claim(ctx, user, tasks[0].TaskID) if err != nil { t.Fatalf("Claim() error = %v", err) } if !ok { t.Fatalf("Claim() = false, want true") } if len(java.changedEvents) != 1 { t.Fatalf("wallet changes = %d, want 1", len(java.changedEvents)) } if java.changedCommands[0].Origin != "DAILY_TASK" { t.Fatalf("wallet origin = %q, want DAILY_TASK", java.changedCommands[0].Origin) } if len(java.changedEvents[0]) > 50 { t.Fatalf("wallet event id length = %d, want <= 50: %s", len(java.changedEvents[0]), java.changedEvents[0]) } ok, err = service.Claim(ctx, user, tasks[0].TaskID) if err != nil { t.Fatalf("Claim() duplicate error = %v", err) } if !ok || len(java.changedEvents) != 1 { t.Fatalf("duplicate claim ok=%v changes=%d, want true/1", ok, len(java.changedEvents)) } } func TestExclusiveTaskConfigUsesLifetimeCycleAndHidesAfterClaim(t *testing.T) { java := &taskCenterTestGateway{events: map[string]bool{}} service, _ := newTestService(t, java) ctx := context.Background() _, err := service.SaveConfig(ctx, SaveConfigRequest{ SysOrigin: "LIKEI", Enabled: true, Timezone: "Asia/Riyadh", TaskCategory: TaskCategoryNewbie, Tasks: []SaveTaskConfigInput{ { TaskCode: "EXCLUSIVE_RECHARGE", Enabled: true, TaskName: "专属充值任务", ConditionType: "RECHARGE_GOLD", TargetValue: 10, TargetUnit: "gold", RewardGold: 100, }, }, }) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } user := common.AuthUser{UserID: 2001, SysOrigin: "LIKEI"} tasks, err := service.ListTasks(ctx, user) if err != nil { t.Fatalf("ListTasks() error = %v", err) } if len(tasks) != 1 || tasks[0].TaskType != 1 { t.Fatalf("exclusive tasks = %+v, want one taskType=1", tasks) } if tasks[0].JumpPage != defaultRechargeJumpPage { t.Fatalf("exclusive recharge jumpPage = %q, want %q", tasks[0].JumpPage, defaultRechargeJumpPage) } eventResp, err := service.ReceiveEvent(ctx, EventRequest{ SysOrigin: "LIKEI", EventID: "recharge-1", EventType: "RECHARGE_GOLD", UserID: flexibleInt64(user.UserID), DeltaValue: 10, }) if err != nil { t.Fatalf("ReceiveEvent() error = %v", err) } if !eventResp.Processed || eventResp.Updated != 1 { t.Fatalf("ReceiveEvent() resp = %+v", eventResp) } ok, err := service.Claim(ctx, user, tasks[0].TaskID) if err != nil { t.Fatalf("Claim() error = %v", err) } if !ok { t.Fatalf("Claim() = false, want true") } tasks, err = service.ListTasks(ctx, user) if err != nil { t.Fatalf("ListTasks() after claim error = %v", err) } if len(tasks) != 0 { t.Fatalf("exclusive tasks after claim = %+v, want hidden", tasks) } } func TestProcessEventWithProductionGameEventShape(t *testing.T) { service, db := newTestService(t, nil) ctx := context.Background() now := time.Now() configRow := model.TaskCenterConfig{ ID: 2052721146352111616, SysOrigin: "LIKEI", Enabled: true, Timezone: "Asia/Riyadh", CreateTime: now, UpdateTime: now, } if err := db.Create(&configRow).Error; err != nil { t.Fatalf("create config: %v", err) } tasks := []model.TaskCenterTaskConfig{ { ID: 2053002284639457280, ConfigID: configRow.ID, SysOrigin: "LIKEI", TaskCode: "GAME_200K", TaskCategory: TaskCategoryDaily, Enabled: true, TaskName: "Spend 200K coins in the game.", TaskDesc: "progress", ConditionType: EventTypeGameConsumeGold, TargetValue: 200000, TargetUnit: "gold", RewardGold: 500, JumpType: "ROOM_RANDOM_WITH_MIC", JumpPage: "/room?gameId=0", SortOrder: 30, CreateTime: now, UpdateTime: now, }, { ID: 2053005491679793152, ConfigID: configRow.ID, SysOrigin: "LIKEI", TaskCode: "GAME_40K", TaskCategory: TaskCategoryNewbie, Enabled: true, TaskName: "Spend 40K coins in the game.", TaskDesc: "progress", ConditionType: EventTypeGameConsumeGold, TargetValue: 40000, TargetUnit: "gold", RewardGold: 200, JumpType: "ROOM_RANDOM_WITH_MIC", SortOrder: 30, CreateTime: now, UpdateTime: now, }, } if err := db.Create(&tasks).Error; err != nil { t.Fatalf("create tasks: %v", err) } resp, err := service.ProcessEvent(ctx, EventRequest{ SysOrigin: "LIKEI", EventID: "BAISHUN_GAME_CONSUME:LIKEI:ee2d4218-4f58-406a-bf0f-d6b09f7e9930", EventType: EventTypeGameConsumeGold, UserID: flexibleInt64(2053169065266049025), DeltaValue: 100, OccurredAt: "2026-05-09T20:48:30.291+03:00", Payload: map[string]any{ "diffMsg": "bet", "gameId": float64(1067), "gameRoundId": "20260510000171", "orderId": "ee2d4218-4f58-406a-bf0f-d6b09f7e9930", "provider": "BAISHUN", }, }) if err != nil { t.Fatalf("ProcessEvent() error = %v", err) } if !resp.Processed || resp.Updated != 2 { t.Fatalf("ProcessEvent() resp = %+v, want processed with two task updates", resp) } var progressCount int64 if err := db.Model(&model.TaskCenterUserProgress{}). Where("sys_origin = ? AND user_id = ? AND completed_value = ?", "LIKEI", int64(2053169065266049025), int64(100)). Count(&progressCount).Error; err != nil { t.Fatalf("count progress: %v", err) } if progressCount != 2 { t.Fatalf("progress rows = %d, want 2", progressCount) } var outbox model.TaskCenterEventArchiveOutbox if err := db.Where("sys_origin = ? AND event_id = ?", "LIKEI", "BAISHUN_GAME_CONSUME:LIKEI:ee2d4218-4f58-406a-bf0f-d6b09f7e9930"). First(&outbox).Error; err != nil { t.Fatalf("load archive outbox: %v", err) } if outbox.Status != ArchiveStatusPending || !strings.Contains(outbox.RawJSON, "BAISHUN_GAME_CONSUME") { t.Fatalf("archive outbox = %+v", outbox) } } func TestConditionJumpConfigAppliesToTasks(t *testing.T) { service, _ := newTestService(t, nil) ctx := context.Background() resp, err := service.SaveConfig(ctx, SaveConfigRequest{ SysOrigin: "LIKEI", Enabled: true, Timezone: "Asia/Riyadh", TaskCategory: TaskCategoryDaily, ConditionJumps: []SaveConditionJumpConfigInput{ { ConditionType: "MIC_DURATION_SECONDS", JumpType: "APP_ROUTE", JumpPage: "/task/mic-guide", SortOrder: 10, }, { ConditionType: "GIFT_CONSUME_GOLD", JumpType: "ROOM_RANDOM", JumpPage: "/room?source=custom", SortOrder: 20, }, }, Tasks: []SaveTaskConfigInput{ { TaskCode: "DAILY_MIC", Enabled: true, TaskName: "Mic task", ConditionType: "MIC_DURATION_SECONDS", TargetValue: 60, TargetUnit: "second", RewardGold: 100, JumpType: "ROOM_RANDOM_WITH_MIC", SortOrder: 10, }, { TaskCode: "DAILY_GIFT", Enabled: true, TaskName: "Gift task", ConditionType: "GIFT_CONSUME_GOLD", TargetValue: 1, TargetUnit: "gold", RewardGold: 100, SortOrder: 20, }, }, }) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } if len(resp.ConditionJumps) != 2 { t.Fatalf("condition jumps = %+v, want 2 rows", resp.ConditionJumps) } if resp.ConditionJumps[1].JumpPage != "/room?source=custom" { t.Fatalf("room condition jump page = %q, want preserved", resp.ConditionJumps[1].JumpPage) } if resp.Tasks[0].JumpType != "APP_ROUTE" || resp.Tasks[0].JumpPage != "/task/mic-guide" { t.Fatalf("mic task jump = %s %s, want APP_ROUTE /task/mic-guide", resp.Tasks[0].JumpType, resp.Tasks[0].JumpPage) } if resp.Tasks[1].JumpType != "ROOM_RANDOM" || resp.Tasks[1].JumpPage != "/room?source=custom" { t.Fatalf("gift task jump = %s %s, want ROOM_RANDOM /room?source=custom", resp.Tasks[1].JumpType, resp.Tasks[1].JumpPage) } tasks, err := service.ListTasks(ctx, common.AuthUser{UserID: 3001, SysOrigin: "LIKEI"}) if err != nil { t.Fatalf("ListTasks() error = %v", err) } if len(tasks) != 2 || tasks[0].JumpPage != "/task/mic-guide" || tasks[1].JumpPage != "/room?source=custom" { t.Fatalf("app task jumps = %+v, want custom route and custom room param", tasks) } } func TestPageClaimRecordsFiltersTimeAndSummarizesGold(t *testing.T) { service, db := newTestService(t, nil) ctx := context.Background() base := time.Date(2026, 5, 15, 10, 0, 0, 0, time.Local) claimedAt := base rows := []model.TaskCenterClaimRecord{ { ID: 1, SysOrigin: "LIKEI", UserID: 1001, TaskID: 11, TaskCode: "DAILY_MIC", TaskName: "Mic", TaskCategory: TaskCategoryDaily, CycleKey: "20260515", RewardGold: 100, WalletEventID: "wallet-1", Status: ClaimStatusSuccess, ClaimedAt: &claimedAt, CreateTime: base, UpdateTime: base, }, { ID: 2, SysOrigin: "LIKEI", UserID: 1002, TaskID: 12, TaskCode: "DAILY_GIFT", TaskName: "Gift", TaskCategory: TaskCategoryDaily, CycleKey: "20260515", RewardGold: 200, WalletEventID: "wallet-2", Status: ClaimStatusSuccess, ClaimedAt: &claimedAt, CreateTime: base.Add(time.Hour), UpdateTime: base.Add(time.Hour), }, { ID: 3, SysOrigin: "LIKEI", UserID: 1003, TaskID: 11, TaskCode: "DAILY_MIC", TaskName: "Mic", TaskCategory: TaskCategoryDaily, CycleKey: "20260515", RewardGold: 999, WalletEventID: "wallet-3", Status: ClaimStatusFailed, FailureReason: "wallet failed", CreateTime: base.Add(2 * time.Hour), UpdateTime: base.Add(2 * time.Hour), }, { ID: 4, SysOrigin: "LIKEI", UserID: 1004, TaskID: 13, TaskCode: "DAILY_OLD", TaskName: "Old", TaskCategory: TaskCategoryDaily, CycleKey: "20260514", RewardGold: 700, WalletEventID: "wallet-4", Status: ClaimStatusSuccess, ClaimedAt: &claimedAt, CreateTime: base.AddDate(0, 0, -1), UpdateTime: base.AddDate(0, 0, -1), }, } if err := db.Create(&rows).Error; err != nil { t.Fatalf("create claim records: %v", err) } resp, err := service.PageClaimRecords( ctx, "LIKEI", TaskCategoryDaily, 0, "daily_mic", "", base.Format("2006-01-02 15:04:05"), base.Add(3*time.Hour).Format("2006-01-02 15:04:05"), 1, 20, ) if err != nil { t.Fatalf("PageClaimRecords() error = %v", err) } if resp.Total != 2 || len(resp.Records) != 2 { t.Fatalf("records total=%d len=%d, want 2", resp.Total, len(resp.Records)) } if resp.Summary.TotalRewardGold != 300 { t.Fatalf("total reward gold = %d, want 300", resp.Summary.TotalRewardGold) } if resp.Summary.SelectedTaskRewardGold != 100 { t.Fatalf("selected task reward gold = %d, want 100", resp.Summary.SelectedTaskRewardGold) } } func TestSaveConfigDerivesConditionJumpsWhenPayloadOmitted(t *testing.T) { service, _ := newTestService(t, nil) ctx := context.Background() resp, err := service.SaveConfig(ctx, SaveConfigRequest{ SysOrigin: "LIKEI", Enabled: true, Timezone: "Asia/Riyadh", TaskCategory: TaskCategoryDaily, Tasks: []SaveTaskConfigInput{ { TaskCode: "DAILY_MIC", Enabled: true, TaskName: "Mic task", ConditionType: "MIC_DURATION_SECONDS", TargetValue: 60, TargetUnit: "second", RewardGold: 100, JumpType: "APP_ROUTE", JumpPage: "/task/mic-guide", SortOrder: 10, }, { TaskCode: "DAILY_GIFT", Enabled: true, TaskName: "Gift task", ConditionType: "GIFT_CONSUME_GOLD", TargetValue: 1, TargetUnit: "gold", RewardGold: 100, JumpType: "ROOM_RANDOM", JumpPage: "/room?source=custom", SortOrder: 20, }, }, }) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } if len(resp.ConditionJumps) != 2 { t.Fatalf("condition jumps = %+v, want derived rows", resp.ConditionJumps) } if resp.ConditionJumps[0].JumpPage != "/task/mic-guide" || resp.ConditionJumps[1].JumpPage != "/room?source=custom" { t.Fatalf("condition jump pages = %+v, want task jump fields persisted", resp.ConditionJumps) } if resp.Tasks[0].JumpType != "APP_ROUTE" || resp.Tasks[0].JumpPage != "/task/mic-guide" { t.Fatalf("mic task jump = %s %s, want APP_ROUTE /task/mic-guide", resp.Tasks[0].JumpType, resp.Tasks[0].JumpPage) } } func TestResolveJumpPageUsesRechargeRouteForRechargeTask(t *testing.T) { tests := []struct { name string task model.TaskCenterTaskConfig want string }{ { name: "recharge task with old room jump type", task: model.TaskCenterTaskConfig{ ConditionType: EventTypeRechargeGold, JumpType: "ROOM_RANDOM_WITH_MIC", }, want: defaultRechargeJumpPage, }, { name: "recharge jump type", task: model.TaskCenterTaskConfig{ JumpType: JumpTypeRechargeInApp, }, want: defaultRechargeJumpPage, }, { name: "explicit jump page wins", task: model.TaskCenterTaskConfig{ ConditionType: EventTypeRechargeGold, JumpPage: "/custom/recharge", JumpType: "ROOM_RANDOM_WITH_MIC", }, want: "/custom/recharge", }, { name: "room task keeps room jump", task: model.TaskCenterTaskConfig{ ConditionType: EventTypeMicDurationSeconds, JumpType: "ROOM_RANDOM_WITH_MIC", }, want: "/room", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := resolveJumpPage(tt.task); got != tt.want { t.Fatalf("resolveJumpPage() = %q, want %q", got, tt.want) } }) } } func TestTaskConfigRejectsRemovedConditionTypes(t *testing.T) { service, _ := newTestService(t, nil) for _, conditionType := range []string{"POST_MOOD_COUNT", "INTERACTIVE_GAME_COUNT", "CP_BIND_COUNT"} { t.Run(conditionType, func(t *testing.T) { _, err := service.SaveConfig(context.Background(), SaveConfigRequest{ SysOrigin: "LIKEI", Enabled: true, Timezone: "Asia/Riyadh", TaskCategory: TaskCategoryDaily, Tasks: []SaveTaskConfigInput{ { TaskCode: "REMOVED_" + conditionType, Enabled: true, TaskName: "Removed condition", ConditionType: conditionType, TargetValue: 1, TargetUnit: "count", RewardGold: 100, }, }, }) if err == nil { t.Fatalf("SaveConfig() error = nil, want invalid condition") } }) } } type taskCenterTestGateway struct { changedEvents []string changedCommands []integration.GoldReceiptCommand events map[string]bool } func (g *taskCenterTestGateway) ExistsGoldEvent(_ context.Context, eventID string) (bool, error) { return g.events[eventID], nil } func (g *taskCenterTestGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error { g.changedEvents = append(g.changedEvents, cmd.EventID) g.changedCommands = append(g.changedCommands, cmd) g.events[cmd.EventID] = true return nil }