452 lines
11 KiB
Go
452 lines
11 KiB
Go
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 ||
|
|
len(detail.OrderSubmissions) != 0 ||
|
|
detail.Events[0].Type != "TASK_CREATED" {
|
|
t.Fatalf("detail = %+v", detail)
|
|
}
|
|
|
|
canceled, err := store.CancelTask(
|
|
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("CancelTask() 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.CancelTask(
|
|
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)
|
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
|
if _, err := db.ExecContext(
|
|
context.Background(),
|
|
`INSERT INTO users (
|
|
id, username, password_hash, role, is_active, created_at, updated_at
|
|
) VALUES (?, 'task-audit-admin', 'test-only-hash', 'ADMIN', 1, ?, ?)`,
|
|
uuid(999),
|
|
now,
|
|
now,
|
|
); err != nil {
|
|
t.Fatalf("seed admin user: %v", err)
|
|
}
|
|
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)
|
|
actorUserID := uuid(999)
|
|
return domain.PurchaseTask{
|
|
ID: uuid(index),
|
|
CreatorSubject: "local-admin",
|
|
CreatedByUserID: &actorUserID,
|
|
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()
|
|
}
|