Files
cmroubao/backend-api/internal/usecase/task_service_test.go
T

233 lines
5.9 KiB
Go

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 ",
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.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 {
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",
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",
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",
TaskID: "00000000-0000-4000-8000-000000000001",
Reason: "no longer needed",
})
assertUsecaseError(t, err, ErrorKindConflict, "TASK_STATE_CONFLICT")
}
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,
_ domain.TaskEvent,
) (domain.PurchaseTask, error) {
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)
}
}