package tasks import ( "context" "database/sql" "errors" "path/filepath" "regexp" "runtime" "strings" "sync" "testing" "time" "cmbuyer/admin/internal/migrations" "cmbuyer/admin/internal/storage/sqlite" ) const testKey = "a3c9f507-7473-4fa6-8d71-8786c34c6301" func TestValidateNormalizesManualDraft(t *testing.T) { draft, validation := Validate(Form{ CreateKey: " " + testKey + " ", Title: " 夏季上衣 ", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=untrusted", SKUColor: " 黑色CHA(纯棉) ", SKUSize: " M(建议100-115) ", Quantity: "2", MaxTotalPrice: "00012.8", }) if !validation.Valid() { t.Fatalf("Validate errors = %#v", validation) } if draft.ID != testKey || draft.GoodsID != "937122477375" || draft.Title != "夏季上衣" || draft.SKUColor != "黑色CHA(纯棉)" || draft.SKUSize != "M(建议100-115)" || draft.Quantity != 2 || draft.MaxTotalPrice != "12.80" { t.Fatalf("normalized draft = %#v", draft) } } func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) { base := Form{CreateKey: testKey, Title: "title", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", SKUColor: "black", SKUSize: "M", Quantity: "1", MaxTotalPrice: "1"} for name, update := range map[string]func(*Form){ "empty title": func(form *Form) { form.Title = " " }, "invalid utf8 title": func(form *Form) { form.Title = string([]byte{0xff}) }, "long title": func(form *Form) { form.Title = strings.Repeat("😀", MaxTitleCodePoints+1) }, "long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) }, "invalid utf8 size": func(form *Form) { form.SKUSize = string([]byte{0xff}) }, "fraction quantity": func(form *Form) { form.Quantity = "1.5" }, "zero quantity": func(form *Form) { form.Quantity = "0" }, "too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" }, "trailing decimal": func(form *Form) { form.MaxTotalPrice = "1." }, "zero money": func(form *Form) { form.MaxTotalPrice = "0.00" }, } { t.Run(name, func(t *testing.T) { form := base update(&form) if _, validation := Validate(form); validation.Valid() { t.Fatal("invalid form was accepted") } }) } for _, value := range []string{ "http://mobile.yangkeduo.com/goods.html?goods_id=1", "https://yangkeduo.com/goods.html?goods_id=1", "https://mobile.yangkeduo.com:443/goods.html?goods_id=1", "https://user@mobile.yangkeduo.com/goods.html?goods_id=1", "https://mobile.yangkeduo.com/goods.html?goods_id=1#fragment", "https://mobile.yangkeduo.com/goods.html?goods_id=1&goods_id=2", "https://mobile.yangkeduo.com/goods.html?goods_id=one", "https://mobile.yangkeduo.com/goods.html?goods_id=%31", "https://mobile.yangkeduo.com/goods.html?goods_id=1%26goods_id%3D2", "https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=bad", "https://mobile.yangkeduo.com/other.html?goods_id=1", "https://mobile.yangkeduo.com/goods.html?goods_id=" + strings.Repeat("1", MaxGoodsIDCharacters+1), } { if _, ok := CanonicalGoodsID(value); ok { t.Fatalf("CanonicalGoodsID accepted %q", value) } } } func TestNormalizeMoneyBoundaries(t *testing.T) { maximum := strings.Repeat("9", MaxMoneyASCIICharacters-3) + ".00" for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00", maximum: maximum} { got, ok := normalizeMoney(value) if !ok || got != want { t.Fatalf("normalizeMoney(%q) = (%q, %t), want (%q, true)", value, got, ok, want) } } for _, value := range []string{"0", "0.0", "0.00", "1.", ".1", "1.000", "-1", "1e2", " 1", strings.Repeat("9", MaxMoneyASCIICharacters-2) + ".00"} { if got, ok := normalizeMoney(value); ok { t.Fatalf("normalizeMoney(%q) = %q, want rejection", value, got) } } } func TestValidateAcceptsWorstLegalUnicodeFieldBounds(t *testing.T) { goodsID := strings.Repeat("1", MaxGoodsIDCharacters) draft, validation := Validate(Form{ CreateKey: testKey, Title: strings.Repeat("😀", MaxTitleCodePoints), ProductURL: CanonicalURL(goodsID), SKUColor: strings.Repeat("色", MaxSKUTextCodePoints), SKUSize: strings.Repeat("码", MaxSKUTextCodePoints), Quantity: "1", MaxTotalPrice: strings.Repeat("9", MaxMoneyASCIICharacters-3) + ".00", }) if !validation.Valid() || !ValidTaskWireFields(draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.MaxTotalPrice) { t.Fatalf("worst legal draft = %#v, validation = %#v", draft, validation) } } func TestPersistedTextHasRuntimeIndependentC0AndNBSPDomain(t *testing.T) { for name, invalid := range map[string]string{ "c0 prefix": "\u001cvalue", "c0 suffix": "value\u001f", "c0 interior": "value\u001dinside", "nbsp prefix": "\u00a0value", "nbsp suffix": "value\u00a0", } { t.Run(name, func(t *testing.T) { if validBoundedText(invalid, MaxTitleCodePoints) { t.Fatalf("validBoundedText(%q) accepted runtime-dependent text", invalid) } }) } if !validBoundedText("left\u00a0right", MaxTitleCodePoints) { t.Fatal("interior NBSP must remain a valid Unicode code point") } // Manual form input is normalized with Go TrimSpace before persistence. draft, validation := Validate(Form{ CreateKey: testKey, Title: "\u00a0title\u00a0", ProductURL: CanonicalURL("1"), SKUColor: "\u00a0black\u00a0", SKUSize: "\u00a0M\u00a0", Quantity: "1", MaxTotalPrice: "1", }) if !validation.Valid() || draft.Title != "title" || draft.SKUColor != "black" || draft.SKUSize != "M" { t.Fatalf("NBSP form normalization = %#v, errors = %#v", draft, validation) } } func TestNewCreateKeyIsUUIDv4(t *testing.T) { key, err := NewCreateKey() if err != nil { t.Fatalf("NewCreateKey: %v", err) } if !regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`).MatchString(key) { t.Fatalf("create key %q is not UUID v4", key) } } func TestSQLiteStoreRequiresMigratedDatabase(t *testing.T) { database := openDatabase(t) if _, err := NewSQLiteStore(database); err == nil { t.Fatal("NewSQLiteStore accepted an unmigrated database") } } func TestSQLiteStoreCreatesListsAndHandlesIdempotency(t *testing.T) { database := migratedDatabase(t) store, err := NewSQLiteStore(database) if err != nil { t.Fatalf("NewSQLiteStore: %v", err) } baseTime := time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) call := 0 store.now = func() time.Time { result := baseTime.Add(time.Duration(call) * time.Minute) call++ return result } first := testDraft(testKey, "first") created, err := store.CreateDraft(context.Background(), first) if err != nil { t.Fatalf("create first draft: %v", err) } replayed, err := store.CreateDraft(context.Background(), first) if err != nil { t.Fatalf("replay first draft: %v", err) } if replayed.CreatedAt != created.CreatedAt { t.Fatalf("replayed CreatedAt = %s, want original %s", replayed.CreatedAt, created.CreatedAt) } second := testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "second") if _, err := store.CreateDraft(context.Background(), second); err != nil { t.Fatalf("create second draft: %v", err) } drafts, err := store.ListDrafts(context.Background()) if err != nil { t.Fatalf("list drafts: %v", err) } if len(drafts) != 2 || drafts[0].ID != second.ID || drafts[1].ID != first.ID { t.Fatalf("draft order = %#v, want second then first", drafts) } var source, status string var version int if err := database.QueryRow(`SELECT source, status, version FROM tasks WHERE id = ?`, first.ID).Scan(&source, &status, &version); err != nil { t.Fatalf("read stored task: %v", err) } if source != "MANUAL" || status != "DRAFT" || version != 1 { t.Fatalf("stored metadata = (%q, %q, %d)", source, status, version) } conflicting := first conflicting.Title = "different" if _, err := store.CreateDraft(context.Background(), conflicting); !errors.Is(err, ErrCreateKeyConflict) { t.Fatalf("conflicting create error = %v, want ErrCreateKeyConflict", err) } } func TestSQLiteStoreRejectsInvalidDraftAtPersistenceBoundary(t *testing.T) { mutations := map[string]func(*Draft){ "untrimmed title": func(draft *Draft) { draft.Title = " title" }, "c0 interior title": func(draft *Draft) { draft.Title = "title\u001dhidden" }, "invalid utf8 title": func(draft *Draft) { draft.Title = string([]byte{0xff}) }, "long title": func(draft *Draft) { draft.Title = strings.Repeat("😀", MaxTitleCodePoints+1) }, "long color": func(draft *Draft) { draft.SKUColor = strings.Repeat("色", MaxSKUTextCodePoints+1) }, "long size": func(draft *Draft) { draft.SKUSize = strings.Repeat("码", MaxSKUTextCodePoints+1) }, "long goods id": func(draft *Draft) { draft.GoodsID = strings.Repeat("1", MaxGoodsIDCharacters+1) }, "long money": func(draft *Draft) { draft.MaxTotalPrice = strings.Repeat("1", MaxMoneyASCIICharacters-2) + ".00" }, } for name, mutate := range mutations { t.Run(name, func(t *testing.T) { database := migratedDatabase(t) store, err := NewSQLiteStore(database) if err != nil { t.Fatal(err) } draft := testDraft(testKey, "title") mutate(&draft) if _, err := store.CreateDraft(context.Background(), draft); !errors.Is(err, ErrInvalidDraft) { t.Fatalf("CreateDraft error = %v, want ErrInvalidDraft", err) } var count int if err := database.QueryRow("SELECT COUNT(*) FROM tasks").Scan(&count); err != nil || count != 0 { t.Fatalf("tasks after invalid create = %d, err %v", count, err) } }) } } func TestSQLiteStoreRollsBackFailedCreate(t *testing.T) { database := migratedDatabase(t) store, err := NewSQLiteStore(database) if err != nil { t.Fatalf("NewSQLiteStore: %v", err) } if _, err := database.Exec(`CREATE TRIGGER reject_task BEFORE INSERT ON tasks BEGIN SELECT RAISE(ABORT, 'reject test insert'); END`); err != nil { t.Fatalf("create trigger: %v", err) } if _, err := store.CreateDraft(context.Background(), testDraft(testKey, "blocked")); err == nil { t.Fatal("CreateDraft succeeded despite rejecting trigger") } drafts, err := store.ListDrafts(context.Background()) if err != nil { t.Fatalf("list after failed create: %v", err) } if len(drafts) != 0 { t.Fatalf("failed create persisted drafts: %#v", drafts) } } func TestSQLiteStoreUsesInsertionOrderForEqualTimesAndFiltersPhase(t *testing.T) { database := migratedDatabase(t) store, err := NewSQLiteStore(database) if err != nil { t.Fatalf("NewSQLiteStore: %v", err) } store.now = func() time.Time { return time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) } first := testDraft(testKey, "first") second := testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "second") for _, draft := range []Draft{first, second} { if _, err := store.CreateDraft(context.Background(), draft); err != nil { t.Fatalf("create %s: %v", draft.Title, err) } } if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES ('excel-draft', 'EXCEL', 'other', '1', 'black', 'M', 1, '1.00', 'DRAFT', 1, '2026-08-04T10:00:00Z', '2026-08-04T10:00:00Z'), ('manual-pending', 'MANUAL', 'other', '2', 'black', 'M', 1, '1.00', 'PENDING', 1, '2026-08-04T10:00:00Z', '2026-08-04T10:00:00Z')`); err != nil { t.Fatalf("insert out-of-scope tasks: %v", err) } drafts, err := store.ListDrafts(context.Background()) if err != nil { t.Fatalf("list drafts: %v", err) } if len(drafts) != 2 || drafts[0].ID != second.ID || drafts[1].ID != first.ID { t.Fatalf("equal-time draft order/filter = %#v, want second then first only", drafts) } if _, err := database.Exec(`UPDATE tasks SET status = 'PENDING' WHERE id = ?`, first.ID); err != nil { t.Fatalf("move draft outside current phase: %v", err) } if _, err := store.CreateDraft(context.Background(), first); !errors.Is(err, ErrCreateKeyConflict) { t.Fatalf("replay of non-DRAFT record error = %v, want conflict", err) } third := testDraft("c3c9f507-7473-4fa6-8d71-8786c34c6301", "third") if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'EXCEL', ?, ?, ?, ?, ?, ?, 'DRAFT', 1, '2026-08-04T09:00:00Z', '2026-08-04T09:00:00Z')`, third.ID, third.Title, third.GoodsID, third.SKUColor, third.SKUSize, third.Quantity, third.MaxTotalPrice); err != nil { t.Fatalf("insert same-payload EXCEL record: %v", err) } if _, err := store.CreateDraft(context.Background(), third); !errors.Is(err, ErrCreateKeyConflict) { t.Fatalf("replay of non-MANUAL record error = %v, want conflict", err) } if _, err := database.Exec(`UPDATE tasks SET version = 2, source = 'MANUAL' WHERE id = ?`, third.ID); err != nil { t.Fatalf("change replay record version: %v", err) } if _, err := store.CreateDraft(context.Background(), third); !errors.Is(err, ErrCreateKeyConflict) { t.Fatalf("replay of non-v1 record error = %v, want conflict", err) } } func TestSQLiteStoreConcurrentIdenticalCreateIsOneDraft(t *testing.T) { store, err := NewSQLiteStore(migratedDatabase(t)) if err != nil { t.Fatalf("NewSQLiteStore: %v", err) } const callers = 20 start := make(chan struct{}) errors := make(chan error, callers) results := make(chan Draft, callers) var group sync.WaitGroup for range callers { group.Add(1) go func() { defer group.Done() <-start draft, err := store.CreateDraft(context.Background(), testDraft(testKey, "same")) if err != nil { errors <- err return } results <- draft }() } close(start) group.Wait() close(errors) close(results) for err := range errors { t.Fatalf("concurrent create: %v", err) } for result := range results { if result.ID != testKey { t.Fatalf("concurrent result = %#v", result) } } drafts, err := store.ListDrafts(context.Background()) if err != nil { t.Fatalf("list after concurrent create: %v", err) } if len(drafts) != 1 || drafts[0].ID != testKey { t.Fatalf("concurrent creates persisted %#v, want exactly one", drafts) } } func testDraft(id, title string) Draft { return Draft{ID: id, Title: title, GoodsID: "937122477375", SKUColor: "black", SKUSize: "M", Quantity: 2, MaxTotalPrice: "12.80"} } func openDatabase(t *testing.T) *sql.DB { t.Helper() database, err := sqlite.Open(filepath.Join(t.TempDir(), "tasks.db")) if err != nil { t.Fatalf("open database: %v", err) } t.Cleanup(func() { _ = database.Close() }) return database } func migratedDatabase(t *testing.T) *sql.DB { t.Helper() database := openDatabase(t) if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil { t.Fatalf("migrate database: %v", err) } return database } func migrationDirectory(t *testing.T) string { t.Helper() _, file, _, ok := runtime.Caller(0) if !ok { t.Fatal("locate test source") } return filepath.Join(filepath.Dir(file), "..", "..", "migrations") }