package usecase import ( "context" "errors" "testing" "time" "cmroubao/backend-api/internal/domain" ) func TestTaskServiceCreateNormalizesAndHashesForIdempotency(t *testing.T) { repository := &fakeTaskRepository{} service := mustTaskService(t, repository) budget := "20.00" sourceRef := " source-1 " result, err := service.Create(context.Background(), CreateTaskCommand{ CreatorSubject: " local-admin ", ActorUserID: "00000000-0000-4000-8000-000000000099", IdempotencyKey: " create-1 ", SourceRef: &sourceRef, Title: " Demo title ", Description: "description", SKU: " SKU-1 ", ImageAssetID: "00000000-0000-4000-8000-000000000001", Quantity: 2, MaxBudget: &budget, }) if err != nil { t.Fatalf("Create() error = %v", err) } if result.Task.Status != domain.TaskStatusPending || result.Task.Title != "Demo title" || result.Task.SKU != "SKU-1" || result.Task.SourceRef == nil || *result.Task.SourceRef != "source-1" || result.Task.CreatedByUserID == nil || *result.Task.CreatedByUserID != "00000000-0000-4000-8000-000000000099" || result.Task.MaxBudgetCents == nil || *result.Task.MaxBudgetCents != 2000 { t.Fatalf("created task = %+v", result.Task) } if repository.key != "create-1" || len(repository.hash) != 64 { t.Fatalf( "idempotency key/hash = %q/%q", repository.key, repository.hash, ) } if repository.event.Type != "TASK_CREATED" || repository.event.TaskID != result.Task.ID || repository.event.ActorUserID == nil || *repository.event.ActorUserID != "00000000-0000-4000-8000-000000000099" { t.Fatalf("event = %+v", repository.event) } } func TestTaskServiceCreateMapsValidationAndRepositoryErrors(t *testing.T) { repository := &fakeTaskRepository{createErr: ErrAssetUnavailable} service := mustTaskService(t, repository) _, err := service.Create(context.Background(), CreateTaskCommand{ CreatorSubject: "local-admin", ActorUserID: "00000000-0000-4000-8000-000000000099", IdempotencyKey: "create-1", Title: "title", SKU: "sku", ImageAssetID: "00000000-0000-4000-8000-000000000001", Quantity: 1, }) assertUsecaseError(t, err, ErrorKindConflict, "TASK_ASSET_UNAVAILABLE") _, err = service.Create(context.Background(), CreateTaskCommand{ CreatorSubject: "local-admin", ActorUserID: "00000000-0000-4000-8000-000000000099", IdempotencyKey: "create-2", Title: "", SKU: "", ImageAssetID: "bad", Quantity: 0, }) assertUsecaseError(t, err, ErrorKindInvalid, "TASK_VALIDATION_FAILED") } func TestTaskServiceListUsesStableOpaqueCursor(t *testing.T) { createdAt := time.Date(2026, 7, 26, 1, 2, 3, 4, time.UTC) repository := &fakeTaskRepository{ listResult: []domain.PurchaseTask{ {ID: "00000000-0000-4000-8000-000000000003", CreatedAt: createdAt}, {ID: "00000000-0000-4000-8000-000000000002", CreatedAt: createdAt}, {ID: "00000000-0000-4000-8000-000000000001", CreatedAt: createdAt}, }, } service := mustTaskService(t, repository) page, err := service.List(context.Background(), ListTasksQuery{ CreatorSubject: "local-admin", Limit: 2, }) if err != nil { t.Fatalf("List() error = %v", err) } if len(page.Items) != 2 || page.NextCursor == "" { t.Fatalf("page = %+v", page) } if repository.filter.Limit != 3 { t.Fatalf("repository limit = %d", repository.filter.Limit) } cursor, err := decodeTaskCursor(page.NextCursor) if err != nil { t.Fatalf("decodeTaskCursor() error = %v", err) } if cursor.ID != page.Items[1].ID || !cursor.CreatedAt.Equal(createdAt) { t.Fatalf("cursor = %+v", cursor) } } func TestTaskServiceCancelMapsStateConflict(t *testing.T) { repository := &fakeTaskRepository{cancelErr: ErrTaskStateConflict} service := mustTaskService(t, repository) _, err := service.Cancel(context.Background(), CancelTaskCommand{ CreatorSubject: "local-admin", ActorUserID: "00000000-0000-4000-8000-000000000099", TaskID: "00000000-0000-4000-8000-000000000001", Reason: "no longer needed", }) assertUsecaseError(t, err, ErrorKindConflict, "TASK_STATE_CONFLICT") if repository.event.ActorUserID == nil || *repository.event.ActorUserID != "00000000-0000-4000-8000-000000000099" { t.Fatalf("cancel event = %+v", repository.event) } } type fakeClock struct{} func (fakeClock) Now() time.Time { return time.Date(2026, 7, 26, 1, 2, 3, 4, time.UTC) } type sequenceIDs struct { next int } func (generator *sequenceIDs) NewID() (string, error) { generator.next++ return "00000000-0000-4000-8000-" + pad12(generator.next), nil } func pad12(value int) string { result := "000000000000" digits := []byte{} for value > 0 { digits = append([]byte{byte('0' + value%10)}, digits...) value /= 10 } if len(digits) == 0 { digits = []byte{'0'} } return result[:12-len(digits)] + string(digits) } type fakeTaskRepository struct { createErr error cancelErr error listResult []domain.PurchaseTask task domain.PurchaseTask event domain.TaskEvent key string hash string filter TaskListFilter } func (repository *fakeTaskRepository) CreateTaskIdempotent( _ context.Context, task domain.PurchaseTask, event domain.TaskEvent, key string, hash string, ) (domain.PurchaseTask, bool, error) { repository.task = task repository.event = event repository.key = key repository.hash = hash return task, true, repository.createErr } func (repository *fakeTaskRepository) ListTasks( _ context.Context, filter TaskListFilter, ) ([]domain.PurchaseTask, error) { repository.filter = filter return repository.listResult, nil } func (repository *fakeTaskRepository) GetTaskDetail( context.Context, string, string, ) (domain.TaskDetail, error) { return domain.TaskDetail{}, errors.New("not implemented") } func (repository *fakeTaskRepository) CancelPendingTask( _ context.Context, _ string, _ string, _ string, _ time.Time, event domain.TaskEvent, ) (domain.PurchaseTask, error) { repository.event = event return domain.PurchaseTask{}, repository.cancelErr } func mustTaskService( t *testing.T, repository TaskRepository, ) *TaskService { t.Helper() service, err := NewTaskService( repository, fakeClock{}, &sequenceIDs{}, ) if err != nil { t.Fatalf("NewTaskService() error = %v", err) } return service } func assertUsecaseError( t *testing.T, err error, kind ErrorKind, code string, ) { t.Helper() var typed *Error if !errors.As(err, &typed) { t.Fatalf("error = %v, want *Error", err) } if typed.Kind != kind || typed.Code != code { t.Fatalf("error = %+v, want kind=%s code=%s", typed, kind, code) } }