233 lines
5.9 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|