feat(admin): add draft task creation
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const sqliteWriteTimeout = 2 * time.Second
|
||||
|
||||
type Store interface {
|
||||
CreateDraft(context.Context, Draft) (Draft, error)
|
||||
ListDrafts(context.Context) ([]Draft, error)
|
||||
}
|
||||
type SQLiteStore struct {
|
||||
database *sql.DB
|
||||
now func() time.Time
|
||||
createGate chan struct{}
|
||||
}
|
||||
|
||||
func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
|
||||
if database == nil {
|
||||
return nil, errors.New("database is required")
|
||||
}
|
||||
if _, err := database.Exec("SELECT 1 FROM tasks LIMIT 1"); err != nil {
|
||||
return nil, fmt.Errorf("tasks migration is not available: %w", err)
|
||||
}
|
||||
return &SQLiteStore{database: database, now: time.Now, createGate: make(chan struct{}, 1)}, nil
|
||||
}
|
||||
|
||||
func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) {
|
||||
writeContext, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
|
||||
defer cancel()
|
||||
// SQLite permits one writer at a time. Serializing this store's short create
|
||||
// transaction prevents concurrent retries of one create key from surfacing as busy.
|
||||
select {
|
||||
case store.createGate <- struct{}{}:
|
||||
defer func() { <-store.createGate }()
|
||||
case <-writeContext.Done():
|
||||
return Draft{}, writeContext.Err()
|
||||
}
|
||||
draft.CreatedAt = store.now().UTC()
|
||||
transaction, err := store.database.BeginTx(writeContext, nil)
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
defer transaction.Rollback()
|
||||
_, err = transaction.ExecContext(writeContext, `INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', ?, ?, ?, ?, ?, ?, 'DRAFT', 1, ?, ?)`, draft.ID, draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.Quantity, draft.MaxTotalPrice, draft.CreatedAt.Format(time.RFC3339Nano), draft.CreatedAt.Format(time.RFC3339Nano))
|
||||
if err == nil {
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
return draft, nil
|
||||
}
|
||||
existing, found, currentPhase, lookupErr := findDraft(writeContext, transaction, draft.ID)
|
||||
if lookupErr != nil {
|
||||
return Draft{}, lookupErr
|
||||
}
|
||||
if found && currentPhase && samePayload(existing, draft) {
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
if found {
|
||||
return Draft{}, ErrCreateKeyConflict
|
||||
}
|
||||
return Draft{}, err
|
||||
}
|
||||
|
||||
func (store *SQLiteStore) ListDrafts(ctx context.Context) ([]Draft, error) {
|
||||
// rowid makes equal timestamps deterministic: SQLite assigns it in insertion order,
|
||||
// whereas UUID v4 is deliberately not time-sortable.
|
||||
rows, err := store.database.QueryContext(ctx, `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at FROM tasks WHERE source = 'MANUAL' AND status = 'DRAFT' ORDER BY created_at DESC, rowid DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []Draft{}
|
||||
for rows.Next() {
|
||||
draft, err := scanDraft(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, draft)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func findDraft(ctx context.Context, transaction *sql.Tx, id string) (Draft, bool, bool, error) {
|
||||
row := transaction.QueryRowContext(ctx, `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at, source, status, version FROM tasks WHERE id = ?`, id)
|
||||
var draft Draft
|
||||
var created, source, status string
|
||||
var version int
|
||||
err := row.Scan(&draft.ID, &draft.Title, &draft.GoodsID, &draft.SKUColor, &draft.SKUSize, &draft.Quantity, &draft.MaxTotalPrice, &created, &source, &status, &version)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Draft{}, false, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Draft{}, false, false, err
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339Nano, created)
|
||||
if err != nil {
|
||||
return Draft{}, false, false, err
|
||||
}
|
||||
draft.CreatedAt = parsed
|
||||
return draft, true, source == "MANUAL" && status == "DRAFT" && version == 1, nil
|
||||
}
|
||||
|
||||
type scanner interface{ Scan(...any) error }
|
||||
|
||||
func scanDraft(row scanner) (Draft, error) {
|
||||
var draft Draft
|
||||
var created string
|
||||
if err := row.Scan(&draft.ID, &draft.Title, &draft.GoodsID, &draft.SKUColor, &draft.SKUSize, &draft.Quantity, &draft.MaxTotalPrice, &created); err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339Nano, created)
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
draft.CreatedAt = parsed
|
||||
return draft, nil
|
||||
}
|
||||
func samePayload(left, right Draft) bool {
|
||||
return left.ID == right.ID && left.Title == right.Title && left.GoodsID == right.GoodsID && left.SKUColor == right.SKUColor && left.SKUSize == right.SKUSize && left.Quantity == right.Quantity && left.MaxTotalPrice == right.MaxTotalPrice
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Package tasks 定义手工 DRAFT 任务的校验与窄仓储边界。
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxTitleLength = 120
|
||||
maxSKUText = 80
|
||||
)
|
||||
|
||||
var ErrCreateKeyConflict = errors.New("create key conflicts with a different task")
|
||||
|
||||
type Draft struct {
|
||||
ID string
|
||||
Title string
|
||||
GoodsID string
|
||||
SKUColor string
|
||||
SKUSize string
|
||||
Quantity int
|
||||
MaxTotalPrice string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Form struct{ CreateKey, Title, ProductURL, SKUColor, SKUSize, Quantity, MaxTotalPrice string }
|
||||
type Errors map[string]string
|
||||
|
||||
func (errors Errors) Valid() bool { return len(errors) == 0 }
|
||||
|
||||
// Validate trims and normalizes a user form. It never reads a product page or derives price data.
|
||||
func Validate(form Form) (Draft, Errors) {
|
||||
draft := Draft{ID: strings.TrimSpace(form.CreateKey), Title: strings.TrimSpace(form.Title), SKUColor: strings.TrimSpace(form.SKUColor), SKUSize: strings.TrimSpace(form.SKUSize)}
|
||||
errors := Errors{}
|
||||
if !validUUID(draft.ID) {
|
||||
errors["create_key"] = "创建请求已过期,请重新打开表单。"
|
||||
}
|
||||
if draft.Title == "" || len([]rune(draft.Title)) > maxTitleLength {
|
||||
errors["title"] = "任务名称不能为空,且不能超过 120 个字符。"
|
||||
}
|
||||
if draft.SKUColor == "" || len([]rune(draft.SKUColor)) > maxSKUText {
|
||||
errors["sku_color"] = "颜色分类不能为空,且不能超过 80 个字符。"
|
||||
}
|
||||
if draft.SKUSize == "" || len([]rune(draft.SKUSize)) > maxSKUText {
|
||||
errors["sku_size"] = "尺码不能为空,且不能超过 80 个字符。"
|
||||
}
|
||||
goodsID, ok := CanonicalGoodsID(strings.TrimSpace(form.ProductURL))
|
||||
if !ok {
|
||||
errors["product_url"] = "请输入唯一的 canonical 商品链接。"
|
||||
} else {
|
||||
draft.GoodsID = goodsID
|
||||
}
|
||||
quantity, err := strconv.ParseInt(strings.TrimSpace(form.Quantity), 10, 0)
|
||||
if err != nil || quantity < 1 {
|
||||
errors["quantity"] = "数量必须是正整数。"
|
||||
} else {
|
||||
draft.Quantity = int(quantity)
|
||||
}
|
||||
money, ok := normalizeMoney(strings.TrimSpace(form.MaxTotalPrice))
|
||||
if !ok {
|
||||
errors["max_total_price"] = "价格上限必须大于零,且最多两位小数。"
|
||||
} else {
|
||||
draft.MaxTotalPrice = money
|
||||
}
|
||||
return draft, errors
|
||||
}
|
||||
|
||||
// CanonicalGoodsID only accepts the one verified manual-entry URL shape; untrusted query data is discarded.
|
||||
func CanonicalGoodsID(value string) (string, bool) {
|
||||
if value == "" || strings.Contains(value, "\\") || strings.Contains(value, "%") {
|
||||
return "", false
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host != "mobile.yangkeduo.com" || parsed.User != nil || parsed.Port() != "" || parsed.Path != "/goods.html" || parsed.Fragment != "" {
|
||||
return "", false
|
||||
}
|
||||
values, err := url.ParseQuery(parsed.RawQuery)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
goodsIDs := values["goods_id"]
|
||||
if len(goodsIDs) != 1 || goodsIDs[0] == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, character := range goodsIDs[0] {
|
||||
if character < '0' || character > '9' {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return goodsIDs[0], true
|
||||
}
|
||||
|
||||
func CanonicalURL(goodsID string) string {
|
||||
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID
|
||||
}
|
||||
|
||||
func NewCreateKey() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
hexValue := hex.EncodeToString(bytes)
|
||||
return hexValue[0:8] + "-" + hexValue[8:12] + "-" + hexValue[12:16] + "-" + hexValue[16:20] + "-" + hexValue[20:32], nil
|
||||
}
|
||||
|
||||
func validUUID(value string) bool {
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
for index, character := range value {
|
||||
if index == 8 || index == 13 || index == 18 || index == 23 {
|
||||
if character != '-' {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
|
||||
}
|
||||
|
||||
func normalizeMoney(value string) (string, bool) {
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) > 2 || parts[0] == "" || len(parts) == 2 && (len(parts[1]) == 0 || len(parts[1]) > 2) {
|
||||
return "", false
|
||||
}
|
||||
for _, character := range parts[0] {
|
||||
if character < '0' || character > '9' {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
fraction := ""
|
||||
if len(parts) == 2 {
|
||||
fraction = parts[1]
|
||||
for _, character := range fraction {
|
||||
if character < '0' || character > '9' {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
}
|
||||
whole := strings.TrimLeft(parts[0], "0")
|
||||
if whole == "" {
|
||||
whole = "0"
|
||||
}
|
||||
if whole == "0" && strings.Trim(fraction, "0") == "" {
|
||||
return "", false
|
||||
}
|
||||
return whole + "." + (fraction + "00")[:2], true
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"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 = " " },
|
||||
"long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) },
|
||||
"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",
|
||||
} {
|
||||
if _, ok := CanonicalGoodsID(value); ok {
|
||||
t.Fatalf("CanonicalGoodsID accepted %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMoneyBoundaries(t *testing.T) {
|
||||
for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00"} {
|
||||
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"} {
|
||||
if got, ok := normalizeMoney(value); ok {
|
||||
t.Fatalf("normalizeMoney(%q) = %q, want rejection", value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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")
|
||||
}
|
||||
Reference in New Issue
Block a user