350 lines
14 KiB
Go
350 lines
14 KiB
Go
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 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" },
|
|
"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")
|
|
}
|