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