package tasks import ( "context" "encoding/json" "errors" "strings" "testing" "time" "git.118im.com/im_team/olons_server/internal/store" ) type fakeRepository struct { config json.RawMessage eligibility *store.TaskEligibility order *store.TaskOrder claimed store.ClaimTaskInput submitted store.TaskSubmissionInput } func (f *fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) { return f.config, nil } func (f *fakeRepository) TaskHalls(context.Context, int64, int64) ([]store.TaskHall, error) { return []store.TaskHall{{ID: 1, Code: "DEFAULT", Name: "任务大厅"}}, nil } func (f *fakeRepository) TaskEligibility(context.Context, int64, int64, int64) (*store.TaskEligibility, error) { return f.eligibility, nil } func (f *fakeRepository) ClaimTask(_ context.Context, input store.ClaimTaskInput) (*store.TaskOrder, error) { f.claimed = input return &store.TaskOrder{ID: 9, OrderNo: input.OrderNo, OrderStatus: 1, Currency: "CNY", DeadlineAt: input.DeadlineAt.Format(time.RFC3339)}, nil } func (f *fakeRepository) AutoMatchTaskOrder(context.Context, int64, int64, int64, int, int64, int64) error { if f.order != nil && f.order.OrderStatus == 1 { f.order.OrderStatus = 2 f.order.ProductID = 7 f.order.ProductName = "自动匹配商品" f.order.Quantity = 1 f.order.TotalAmount = 10000 f.order.CommissionAmount = 100 } return nil } func (f *fakeRepository) TaskOrders(context.Context, int64, int64, int, int, int) (*store.TaskOrderPage, error) { return &store.TaskOrderPage{Items: []store.TaskOrder{*f.order}, Total: 1}, nil } func (f *fakeRepository) TaskOrder(context.Context, int64, int64, int64) (*store.TaskOrder, error) { return f.order, nil } func (f *fakeRepository) TaskStats(context.Context, int64, int64) (*store.TaskStats, error) { return &store.TaskStats{AvailableAmount: 100, FrozenAmount: 25}, nil } func (f *fakeRepository) SubmitTask(_ context.Context, input store.TaskSubmissionInput) (*store.TaskSubmissionResult, error) { f.submitted = input return &store.TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: 4, SubmittedAt: time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)}, nil } func (f *fakeRepository) OrderLedgers(context.Context, int64, int64, []string) ([]map[string]any, error) { return []map[string]any{}, nil } func TestEligibilityRequiresFixedMinimumBalance(t *testing.T) { repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":0}`), eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 1, HallEligible: true, AvailableAmount: 29899, WalletStatus: 1, DailyLimit: 5, TodayCount: 2}} service := New(repository) result, err := service.Eligibility(context.Background(), 0, 10, 1) if err != nil { t.Fatal(err) } if result.Eligible || result.Reason != "INSUFFICIENT_BALANCE" || result.MinimumAmount != 29900 { t.Fatalf("unexpected eligibility: %#v", result) } } func TestEligibilityAlsoRequiresMinimumForManualDispatch(t *testing.T) { repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true}}`), eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 2, HallEligible: true, AvailableAmount: 29899, WalletStatus: 1, DailyLimit: 5}} service := New(repository) result, err := service.Eligibility(context.Background(), 0, 10, 1) if err != nil { t.Fatal(err) } if result.Eligible || result.Reason != "INSUFFICIENT_BALANCE" { t.Fatalf("manual dispatch must use the same minimum balance gate: %#v", result) } } func TestEligibilityDoesNotEnforceDisplayedBusinessHours(t *testing.T) { repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_business_start_at":"09:00","task_business_end_at":"21:00"}`), eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 1, HallEligible: true, AvailableAmount: 29900, WalletStatus: 1, DailyLimit: 5}} service := New(repository) service.now = func() time.Time { return time.Date(2026, 10, 9, 23, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) } result, err := service.Eligibility(context.Background(), 0, 10, 1) if err != nil { t.Fatal(err) } if !result.Eligible || result.BusinessStartAt != "09:00:00+08:00" || result.BusinessEndAt != "21:00:00+08:00" { t.Fatalf("business hours are display-only: %#v", result) } } func TestClaimDefaultsToEnabledWithoutTaskSwitch(t *testing.T) { repository := &fakeRepository{config: json.RawMessage(`{}`)} service := New(repository) service.now = func() time.Time { return time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC) } if _, err := service.Claim(context.Background(), 0, 10, 1); err != nil { t.Fatalf("missing switch should default to enabled, got %v", err) } } func TestClaimHonorsExplicitDisabledTaskSwitch(t *testing.T) { repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":false}}`)} service := New(repository) _, err := service.Claim(context.Background(), 0, 10, 1) if !errors.Is(err, ErrTaskDisabled) { t.Fatalf("expected explicitly disabled task, got %v", err) } } func TestClaimBuildsMatchingOrder(t *testing.T) { repository := &fakeRepository{config: json.RawMessage(`{"task_enabled":true,"task_deadline_minutes":20}`)} service := New(repository) now := time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC) service.now = func() time.Time { return now } result, err := service.Claim(context.Background(), 0, 10, 2) if err != nil { t.Fatal(err) } if result.Status != "matching" || repository.claimed.HallID != 2 || repository.claimed.MinimumAmount != 29900 || !repository.claimed.DeadlineAt.Equal(now.Add(20*time.Minute)) { t.Fatalf("unexpected claim: result=%#v input=%#v", result, repository.claimed) } } func TestSubmitAutomaticallySettlesWithoutEvidence(t *testing.T) { repository := &fakeRepository{} service := New(repository) service.now = func() time.Time { return time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC) } result, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{}) if err != nil { t.Fatal(err) } if result.Status != "settled" || result.OrderStatus != "completed" || repository.submitted.IdempotencyKey != "task:9:auto-settle" || repository.submitted.LedgerNo == "" { t.Fatalf("unexpected submission: result=%#v input=%#v", result, repository.submitted) } } func TestSubmitStillValidatesOptionalEvidence(t *testing.T) { service := New(&fakeRepository{}) tooLong := strings.Repeat("字", 2001) if _, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{EvidenceText: tooLong}); !errors.Is(err, ErrEvidenceInvalid) { t.Fatalf("expected evidence error, got %v", err) } } func TestOrdersRejectsUnknownStatus(t *testing.T) { service := New(&fakeRepository{}) _, err := service.Orders(context.Background(), 0, 10, 1, 20, "not-a-status") if !errors.Is(err, ErrStatusInvalid) { t.Fatalf("expected status error, got %v", err) } }