feat(backend): implement task creation and admin web
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user