feat(backend): implement task creation and admin web
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
platformdatabase "cmroubao/backend-api/internal/platform/database"
|
||||
"cmroubao/backend-api/internal/platform/migration"
|
||||
repository "cmroubao/backend-api/internal/repository/sqlite"
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
)
|
||||
|
||||
func TestMigrationRejectsTaskFieldLimitViolations(t *testing.T) {
|
||||
db := openDatabase(t)
|
||||
ctx := context.Background()
|
||||
asset := testAsset(1, time.Now().UTC())
|
||||
_, err := db.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO assets (
|
||||
id, creator_subject, purpose, media_type, size_bytes,
|
||||
sha256, storage_key, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
asset.ID,
|
||||
asset.CreatorSubject,
|
||||
asset.Purpose,
|
||||
asset.MediaType,
|
||||
asset.SizeBytes,
|
||||
asset.SHA256,
|
||||
asset.StorageKey,
|
||||
asset.CreatedAt.Format(time.RFC3339Nano),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert asset: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
title string
|
||||
description string
|
||||
sku string
|
||||
sourceRef string
|
||||
}{
|
||||
{
|
||||
name: "title characters",
|
||||
title: repeatText("a", domain.MaxTitleRunes+1),
|
||||
description: "",
|
||||
sku: "sku",
|
||||
sourceRef: "source-1",
|
||||
},
|
||||
{
|
||||
name: "SKU bytes",
|
||||
title: "title",
|
||||
description: "",
|
||||
sku: repeatText("a", domain.MaxSKUBytes+1),
|
||||
sourceRef: "source-1",
|
||||
},
|
||||
{
|
||||
name: "description bytes",
|
||||
title: "title",
|
||||
description: repeatText("a", domain.MaxDescriptionBytes+1),
|
||||
sku: "sku",
|
||||
sourceRef: "source-1",
|
||||
},
|
||||
{
|
||||
name: "source reference bytes",
|
||||
title: "title",
|
||||
description: "",
|
||||
sku: "sku",
|
||||
sourceRef: repeatText("a", domain.MaxSourceRefBytes+1),
|
||||
},
|
||||
}
|
||||
for index, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := db.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO purchase_tasks (
|
||||
id, creator_subject, source_ref, title, description, sku,
|
||||
image_asset_id, quantity, max_budget_cents, currency,
|
||||
status, version, created_at, updated_at
|
||||
) VALUES (?, 'local-admin', ?, ?, ?, ?, ?, 1, NULL, 'CNY',
|
||||
'PENDING', 1, ?, ?)`,
|
||||
uuid(500+index),
|
||||
test.sourceRef,
|
||||
test.title,
|
||||
test.description,
|
||||
test.sku,
|
||||
asset.ID,
|
||||
asset.CreatedAt.Format(time.RFC3339Nano),
|
||||
asset.CreatedAt.Format(time.RFC3339Nano),
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("constraint violation error = nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAssetAndTaskLifecycleIsTransactionalAndIdempotent(
|
||||
t *testing.T,
|
||||
) {
|
||||
store := openStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 7, 26, 2, 3, 4, 5, time.UTC)
|
||||
asset := testAsset(1, now)
|
||||
|
||||
createdAsset, created, err := store.CreateAssetIdempotent(
|
||||
ctx,
|
||||
asset,
|
||||
"asset-key-1",
|
||||
repeatHex("1"),
|
||||
)
|
||||
if err != nil || !created || createdAsset.ID != asset.ID {
|
||||
t.Fatalf(
|
||||
"CreateAssetIdempotent() = %+v, %t, %v",
|
||||
createdAsset,
|
||||
created,
|
||||
err,
|
||||
)
|
||||
}
|
||||
replayedAsset, created, err := store.CreateAssetIdempotent(
|
||||
ctx,
|
||||
testAsset(2, now.Add(time.Second)),
|
||||
"asset-key-1",
|
||||
repeatHex("1"),
|
||||
)
|
||||
if err != nil || created || replayedAsset.ID != asset.ID {
|
||||
t.Fatalf(
|
||||
"asset replay = %+v, %t, %v",
|
||||
replayedAsset,
|
||||
created,
|
||||
err,
|
||||
)
|
||||
}
|
||||
_, _, err = store.CreateAssetIdempotent(
|
||||
ctx,
|
||||
testAsset(3, now.Add(2*time.Second)),
|
||||
"asset-key-1",
|
||||
repeatHex("2"),
|
||||
)
|
||||
if !errors.Is(err, usecase.ErrIdempotencyConflict) {
|
||||
t.Fatalf("different asset replay error = %v", err)
|
||||
}
|
||||
|
||||
task := testTask(1, asset.ID, "source-1", now)
|
||||
event := testEvent(1, task.ID, "TASK_CREATED", now)
|
||||
createdTask, created, err := store.CreateTaskIdempotent(
|
||||
ctx,
|
||||
task,
|
||||
event,
|
||||
"task-key-1",
|
||||
repeatHex("3"),
|
||||
)
|
||||
if err != nil || !created || createdTask.Status != domain.TaskStatusPending {
|
||||
t.Fatalf(
|
||||
"CreateTaskIdempotent() = %+v, %t, %v",
|
||||
createdTask,
|
||||
created,
|
||||
err,
|
||||
)
|
||||
}
|
||||
replayedTask, created, err := store.CreateTaskIdempotent(
|
||||
ctx,
|
||||
testTask(2, asset.ID, "different", now.Add(time.Second)),
|
||||
testEvent(2, uuid(2), "TASK_CREATED", now.Add(time.Second)),
|
||||
"task-key-1",
|
||||
repeatHex("3"),
|
||||
)
|
||||
if err != nil || created || replayedTask.ID != task.ID {
|
||||
t.Fatalf(
|
||||
"task replay = %+v, %t, %v",
|
||||
replayedTask,
|
||||
created,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
detail, err := store.GetTaskDetail(ctx, "local-admin", task.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskDetail() error = %v", err)
|
||||
}
|
||||
if detail.Asset.ID != asset.ID ||
|
||||
len(detail.Events) != 1 ||
|
||||
detail.Events[0].Type != "TASK_CREATED" {
|
||||
t.Fatalf("detail = %+v", detail)
|
||||
}
|
||||
|
||||
canceled, err := store.CancelPendingTask(
|
||||
ctx,
|
||||
"local-admin",
|
||||
task.ID,
|
||||
"no longer needed",
|
||||
now.Add(time.Minute),
|
||||
testEvent(3, task.ID, "TASK_CANCELED", now.Add(time.Minute)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CancelPendingTask() error = %v", err)
|
||||
}
|
||||
if canceled.Status != domain.TaskStatusCanceled ||
|
||||
canceled.Version != 2 ||
|
||||
canceled.CancelReason == nil ||
|
||||
*canceled.CancelReason != "no longer needed" {
|
||||
t.Fatalf("canceled task = %+v", canceled)
|
||||
}
|
||||
_, err = store.CancelPendingTask(
|
||||
ctx,
|
||||
"local-admin",
|
||||
task.ID,
|
||||
"again",
|
||||
now.Add(2*time.Minute),
|
||||
testEvent(4, task.ID, "TASK_CANCELED", now.Add(2*time.Minute)),
|
||||
)
|
||||
if !errors.Is(err, usecase.ErrTaskStateConflict) {
|
||||
t.Fatalf("repeat cancel error = %v", err)
|
||||
}
|
||||
detail, err = store.GetTaskDetail(ctx, "local-admin", task.ID)
|
||||
if err != nil || len(detail.Events) != 2 {
|
||||
t.Fatalf("canceled detail events = %d, error = %v", len(detail.Events), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreEnforcesAssetOwnershipSourceReferenceAndStableCursor(
|
||||
t *testing.T,
|
||||
) {
|
||||
store := openStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 7, 26, 3, 4, 5, 0, time.UTC)
|
||||
|
||||
for index := 1; index <= 4; index++ {
|
||||
asset := testAsset(index, now)
|
||||
if _, _, err := store.CreateAssetIdempotent(
|
||||
ctx,
|
||||
asset,
|
||||
"asset-key-"+string(rune('0'+index)),
|
||||
repeatHex(string(rune('0'+index))),
|
||||
); err != nil {
|
||||
t.Fatalf("create asset %d: %v", index, err)
|
||||
}
|
||||
if index <= 3 {
|
||||
task := testTask(index, asset.ID, "source-"+string(rune('0'+index)), now)
|
||||
if _, _, err := store.CreateTaskIdempotent(
|
||||
ctx,
|
||||
task,
|
||||
testEvent(index, task.ID, "TASK_CREATED", now),
|
||||
"task-key-"+string(rune('0'+index)),
|
||||
repeatHex(string(rune('4'+index))),
|
||||
); err != nil {
|
||||
t.Fatalf("create task %d: %v", index, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
first, err := store.ListTasks(ctx, usecase.TaskListFilter{
|
||||
CreatorSubject: "local-admin",
|
||||
Query: "title",
|
||||
Limit: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first ListTasks() error = %v", err)
|
||||
}
|
||||
if len(first) != 2 || first[0].ID <= first[1].ID {
|
||||
t.Fatalf("first page order = %+v", first)
|
||||
}
|
||||
second, err := store.ListTasks(ctx, usecase.TaskListFilter{
|
||||
CreatorSubject: "local-admin",
|
||||
Limit: 2,
|
||||
After: &usecase.TaskCursor{
|
||||
CreatedAt: first[1].CreatedAt,
|
||||
ID: first[1].ID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second ListTasks() error = %v", err)
|
||||
}
|
||||
if len(second) != 1 || second[0].ID == first[0].ID ||
|
||||
second[0].ID == first[1].ID {
|
||||
t.Fatalf("second page = %+v", second)
|
||||
}
|
||||
|
||||
conflicting := testTask(4, testAsset(4, now).ID, "source-1", now)
|
||||
_, _, err = store.CreateTaskIdempotent(
|
||||
ctx,
|
||||
conflicting,
|
||||
testEvent(4, conflicting.ID, "TASK_CREATED", now),
|
||||
"task-key-conflict",
|
||||
repeatHex("a"),
|
||||
)
|
||||
if !errors.Is(err, usecase.ErrSourceReferenceConflict) {
|
||||
t.Fatalf("source conflict error = %v", err)
|
||||
}
|
||||
|
||||
otherAsset := testAsset(8, now)
|
||||
otherAsset.CreatorSubject = "other-admin"
|
||||
if _, _, err := store.CreateAssetIdempotent(
|
||||
ctx,
|
||||
otherAsset,
|
||||
"other-asset",
|
||||
repeatHex("b"),
|
||||
); err != nil {
|
||||
t.Fatalf("create other asset: %v", err)
|
||||
}
|
||||
foreignTask := testTask(8, otherAsset.ID, "foreign-source", now)
|
||||
foreignTask.CreatorSubject = "local-admin"
|
||||
_, _, err = store.CreateTaskIdempotent(
|
||||
ctx,
|
||||
foreignTask,
|
||||
testEvent(8, foreignTask.ID, "TASK_CREATED", now),
|
||||
"foreign-task",
|
||||
repeatHex("c"),
|
||||
)
|
||||
if !errors.Is(err, usecase.ErrAssetUnavailable) {
|
||||
t.Fatalf("foreign asset error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func openStore(t *testing.T) *repository.Store {
|
||||
t.Helper()
|
||||
db := openDatabase(t)
|
||||
store, err := repository.New(db)
|
||||
if err != nil {
|
||||
t.Fatalf("repository.New() error = %v", err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func openDatabase(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := platformdatabase.Open(
|
||||
context.Background(),
|
||||
filepath.Join(t.TempDir(), "store.db"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("database.Open() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
runner, err := migration.New(db)
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if _, err := runner.Up(context.Background()); err != nil {
|
||||
t.Fatalf("migration Up() error = %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func testAsset(index int, createdAt time.Time) domain.Asset {
|
||||
return domain.Asset{
|
||||
ID: uuid(100 + index),
|
||||
CreatorSubject: "local-admin",
|
||||
Purpose: domain.AssetPurposeTaskReference,
|
||||
MediaType: domain.NormalizedImageMediaType,
|
||||
SizeBytes: int64(1000 + index),
|
||||
SHA256: repeatHex("d"),
|
||||
StorageKey: "aa/" + uuid(200+index) + ".jpg",
|
||||
CreatedAt: createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
func testTask(
|
||||
index int,
|
||||
assetID string,
|
||||
source string,
|
||||
createdAt time.Time,
|
||||
) domain.PurchaseTask {
|
||||
budget := int64(2000 + index)
|
||||
return domain.PurchaseTask{
|
||||
ID: uuid(index),
|
||||
CreatorSubject: "local-admin",
|
||||
SourceRef: &source,
|
||||
Title: "title " + string(rune('0'+index)),
|
||||
Description: "description",
|
||||
SKU: "SKU-" + string(rune('0'+index)),
|
||||
ImageAssetID: assetID,
|
||||
Quantity: index,
|
||||
MaxBudgetCents: &budget,
|
||||
Currency: domain.CurrencyCNY,
|
||||
Status: domain.TaskStatusPending,
|
||||
Version: 1,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
func testEvent(
|
||||
index int,
|
||||
taskID string,
|
||||
eventType string,
|
||||
occurredAt time.Time,
|
||||
) domain.TaskEvent {
|
||||
return domain.TaskEvent{
|
||||
ID: uuid(300 + index),
|
||||
TaskID: taskID,
|
||||
Type: eventType,
|
||||
Message: "event",
|
||||
OccurredAt: occurredAt,
|
||||
}
|
||||
}
|
||||
|
||||
func uuid(index int) string {
|
||||
return "00000000-0000-4000-8000-" + twelveDigits(index)
|
||||
}
|
||||
|
||||
func twelveDigits(value int) string {
|
||||
result := "000000000000"
|
||||
digits := ""
|
||||
for value > 0 {
|
||||
digits = string(rune('0'+value%10)) + digits
|
||||
value /= 10
|
||||
}
|
||||
if digits == "" {
|
||||
digits = "0"
|
||||
}
|
||||
return result[:len(result)-len(digits)] + digits
|
||||
}
|
||||
|
||||
func repeatHex(value string) string {
|
||||
result := ""
|
||||
for len(result) < 64 {
|
||||
result += value
|
||||
}
|
||||
return result[:64]
|
||||
}
|
||||
|
||||
func repeatText(value string, count int) string {
|
||||
var result strings.Builder
|
||||
result.Grow(len(value) * count)
|
||||
for index := 0; index < count; index++ {
|
||||
result.WriteString(value)
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
Reference in New Issue
Block a user