Files
cmroubao/backend-api/internal/repository/sqlite/store_test.go
T

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()
}