2026-07-28 23:51:59 +08:00
|
|
|
package sqlite_test
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"cmroubao/backend-api/internal/domain"
|
|
|
|
|
"cmroubao/backend-api/internal/platform/migration"
|
|
|
|
|
repository "cmroubao/backend-api/internal/repository/sqlite"
|
|
|
|
|
"cmroubao/backend-api/internal/usecase"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestProcurementRequestsArePerItemAndTaskSnapshotIsImmutable(
|
|
|
|
|
t *testing.T,
|
|
|
|
|
) {
|
|
|
|
|
db := openDatabase(t)
|
|
|
|
|
store, _ := repository.New(db)
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
now := time.Date(2026, 7, 28, 5, 6, 7, 0, time.UTC)
|
|
|
|
|
userID := uuid(1100)
|
|
|
|
|
seedFreightUser(t, db, userID, now)
|
|
|
|
|
run := freightRun(1101, userID, now)
|
|
|
|
|
createAndStartFreightRun(t, store, run, "procurement-source-1", "1")
|
|
|
|
|
if err := store.CompleteFreightSync(
|
|
|
|
|
ctx,
|
|
|
|
|
run,
|
|
|
|
|
freightBatch(1110, "a", "b"),
|
|
|
|
|
now.Add(time.Second),
|
|
|
|
|
); err != nil {
|
|
|
|
|
t.Fatalf("CompleteFreightSync() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
orders, _ := store.ListFreightOrders(ctx, "local-admin", 10)
|
|
|
|
|
detail, _ := store.GetFreightOrder(ctx, "local-admin", orders[0].ID)
|
|
|
|
|
service, err := usecase.NewProcurementService(
|
|
|
|
|
store,
|
|
|
|
|
procurementClock{now: now.Add(2 * time.Second)},
|
|
|
|
|
&procurementIDs{next: 1200},
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("NewProcurementService() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
requests := make([]domain.ProcurementRequest, 0, len(detail.Items))
|
|
|
|
|
for _, item := range detail.Items {
|
|
|
|
|
result, err := service.CreateRequest(
|
|
|
|
|
ctx,
|
|
|
|
|
usecase.CreateProcurementRequestCommand{
|
|
|
|
|
CreatorSubject: "local-admin",
|
|
|
|
|
ActorUserID: userID,
|
|
|
|
|
FreightOrderItemID: item.ID,
|
|
|
|
|
ConfirmProcurementNeeded: true,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if err != nil || result.Request.Status !=
|
|
|
|
|
domain.ProcurementNeedsImage {
|
|
|
|
|
t.Fatalf("CreateRequest() = %+v, %v", result, err)
|
|
|
|
|
}
|
|
|
|
|
requests = append(requests, result.Request)
|
|
|
|
|
}
|
|
|
|
|
if len(requests) != 2 || requests[0].ID == requests[1].ID {
|
|
|
|
|
t.Fatalf("requests = %+v", requests)
|
|
|
|
|
}
|
|
|
|
|
replay, err := service.CreateRequest(
|
|
|
|
|
ctx,
|
|
|
|
|
usecase.CreateProcurementRequestCommand{
|
|
|
|
|
CreatorSubject: "local-admin",
|
|
|
|
|
ActorUserID: userID,
|
|
|
|
|
FreightOrderItemID: detail.Items[0].ID,
|
|
|
|
|
ConfirmProcurementNeeded: true,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if err != nil || !replay.Replayed ||
|
|
|
|
|
replay.Request.ID != requests[0].ID {
|
|
|
|
|
t.Fatalf("request replay = %+v, %v", replay, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
asset := domain.Asset{
|
|
|
|
|
ID: uuid(1300),
|
|
|
|
|
CreatorSubject: "local-admin",
|
|
|
|
|
Purpose: domain.AssetPurposeTaskReference,
|
|
|
|
|
MediaType: domain.NormalizedImageMediaType,
|
|
|
|
|
SizeBytes: 100,
|
|
|
|
|
SHA256: repeatHex("f"),
|
|
|
|
|
StorageKey: "procurement/reference.jpg",
|
|
|
|
|
CreatedAt: now,
|
|
|
|
|
}
|
|
|
|
|
if _, _, err := store.CreateAssetIdempotent(
|
|
|
|
|
ctx,
|
|
|
|
|
asset,
|
|
|
|
|
"procurement-asset",
|
|
|
|
|
repeatHex("e"),
|
|
|
|
|
); err != nil {
|
|
|
|
|
t.Fatalf("CreateAssetIdempotent() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
bound, err := service.BindReference(
|
|
|
|
|
ctx,
|
|
|
|
|
usecase.BindProcurementReferenceCommand{
|
|
|
|
|
CreatorSubject: "local-admin",
|
|
|
|
|
ActorUserID: userID,
|
|
|
|
|
RequestID: requests[0].ID,
|
|
|
|
|
ImageAssetID: asset.ID,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if err != nil || bound.Status != domain.ProcurementReady {
|
|
|
|
|
t.Fatalf("BindReference() = %+v, %v", bound, err)
|
|
|
|
|
}
|
|
|
|
|
created, err := service.CreateTask(
|
|
|
|
|
ctx,
|
|
|
|
|
usecase.CreateProcurementTaskCommand{
|
|
|
|
|
CreatorSubject: "local-admin",
|
|
|
|
|
ActorUserID: userID,
|
|
|
|
|
RequestID: requests[0].ID,
|
|
|
|
|
IdempotencyKey: "procurement-task-1",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if err != nil || created.Task.Status != domain.TaskStatusPending ||
|
|
|
|
|
created.Task.Title != detail.Items[0].Title ||
|
|
|
|
|
created.Task.SKU != detail.Items[0].SKU ||
|
|
|
|
|
created.Task.ImageAssetID != asset.ID {
|
|
|
|
|
t.Fatalf("CreateTask() = %+v, %v", created, err)
|
|
|
|
|
}
|
|
|
|
|
replayedTask, err := service.CreateTask(
|
|
|
|
|
ctx,
|
|
|
|
|
usecase.CreateProcurementTaskCommand{
|
|
|
|
|
CreatorSubject: "local-admin",
|
|
|
|
|
ActorUserID: userID,
|
|
|
|
|
RequestID: requests[0].ID,
|
|
|
|
|
IdempotencyKey: "procurement-task-2",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if err != nil || !replayedTask.Replayed ||
|
|
|
|
|
replayedTask.Task.ID != created.Task.ID {
|
|
|
|
|
t.Fatalf("task replay = %+v, %v", replayedTask, err)
|
|
|
|
|
}
|
|
|
|
|
buyerID := uuid(1600)
|
|
|
|
|
deviceID := uuid(1601)
|
|
|
|
|
seedLifecycleUser(
|
|
|
|
|
t,
|
|
|
|
|
db,
|
|
|
|
|
buyerID,
|
|
|
|
|
"procurement-buyer",
|
|
|
|
|
domain.UserRoleBuyer,
|
|
|
|
|
now,
|
|
|
|
|
)
|
|
|
|
|
seedLifecycleDevice(
|
|
|
|
|
t,
|
|
|
|
|
db,
|
|
|
|
|
deviceID,
|
|
|
|
|
"procurement-device",
|
|
|
|
|
buyerID,
|
|
|
|
|
repeatHex("6"),
|
|
|
|
|
now,
|
|
|
|
|
)
|
|
|
|
|
lifecycle := &lifecycleFixture{
|
|
|
|
|
store: store,
|
|
|
|
|
db: db,
|
|
|
|
|
buyerOneID: buyerID,
|
|
|
|
|
deviceOneID: deviceID,
|
|
|
|
|
}
|
|
|
|
|
readyAt := now.Add(3 * time.Second)
|
|
|
|
|
recordReadyHeartbeat(t, lifecycle, buyerID, deviceID, readyAt)
|
|
|
|
|
claimed, err := store.ClaimNext(
|
|
|
|
|
ctx,
|
|
|
|
|
lifecycleClaimRequest(
|
|
|
|
|
lifecycle,
|
|
|
|
|
buyerID,
|
|
|
|
|
deviceID,
|
|
|
|
|
1602,
|
|
|
|
|
readyAt.Add(time.Second),
|
|
|
|
|
readyAt.Add(10*time.Minute),
|
|
|
|
|
readyAt.Add(-time.Minute),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
if err != nil || claimed.Task == nil ||
|
|
|
|
|
claimed.Task.ID != created.Task.ID ||
|
|
|
|
|
claimed.Task.Status != domain.TaskStatusClaimed {
|
|
|
|
|
t.Fatalf("Roubao claim = %+v, %v", claimed, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateRun := freightRun(1102, userID, now.Add(time.Minute))
|
|
|
|
|
createAndStartFreightRun(
|
|
|
|
|
t,
|
|
|
|
|
store,
|
|
|
|
|
updateRun,
|
|
|
|
|
"procurement-source-2",
|
|
|
|
|
"2",
|
|
|
|
|
)
|
|
|
|
|
changed := freightBatch(1120, "c", "b")
|
|
|
|
|
changed.Orders[0].CanonicalSHA256 = repeatHex("d")
|
|
|
|
|
changed.Orders[0].Items[0].Title = "ERP 更新后的标题"
|
|
|
|
|
changed.Orders[0].Items[0].CanonicalSHA256 = repeatHex("c")
|
|
|
|
|
if err := store.CompleteFreightSync(
|
|
|
|
|
ctx,
|
|
|
|
|
updateRun,
|
|
|
|
|
changed,
|
|
|
|
|
now.Add(time.Minute+time.Second),
|
|
|
|
|
); err != nil {
|
|
|
|
|
t.Fatalf("updated freight import error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
storedRequest, err := service.Get(
|
|
|
|
|
ctx,
|
|
|
|
|
"local-admin",
|
|
|
|
|
requests[0].ID,
|
|
|
|
|
)
|
|
|
|
|
if err != nil || !storedRequest.SourceChanged ||
|
|
|
|
|
storedRequest.PurchaseTaskID == nil ||
|
|
|
|
|
*storedRequest.PurchaseTaskID != created.Task.ID {
|
|
|
|
|
t.Fatalf("source changed request = %+v, %v", storedRequest, err)
|
|
|
|
|
}
|
|
|
|
|
replayAfterUpdate, err := service.CreateTask(
|
|
|
|
|
ctx,
|
|
|
|
|
usecase.CreateProcurementTaskCommand{
|
|
|
|
|
CreatorSubject: "local-admin",
|
|
|
|
|
ActorUserID: userID,
|
|
|
|
|
RequestID: requests[0].ID,
|
|
|
|
|
IdempotencyKey: "procurement-task-after-source-update",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if err != nil || !replayAfterUpdate.Replayed ||
|
|
|
|
|
replayAfterUpdate.Task.ID != created.Task.ID {
|
|
|
|
|
t.Fatalf(
|
|
|
|
|
"task replay after source update = %+v, %v",
|
|
|
|
|
replayAfterUpdate,
|
|
|
|
|
err,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
storedTask, err := store.GetTaskDetail(
|
|
|
|
|
ctx,
|
|
|
|
|
"local-admin",
|
|
|
|
|
created.Task.ID,
|
|
|
|
|
)
|
|
|
|
|
if err != nil || storedTask.Task.Title != detail.Items[0].Title ||
|
|
|
|
|
storedTask.Task.Title == "ERP 更新后的标题" {
|
|
|
|
|
t.Fatalf("immutable task = %+v, %v", storedTask.Task, err)
|
|
|
|
|
}
|
|
|
|
|
runner, err := migration.New(db)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("migration.New() error = %v", err)
|
|
|
|
|
}
|
2026-07-29 15:12:05 +08:00
|
|
|
if err := runner.Down(ctx); err != nil {
|
|
|
|
|
t.Fatalf("image migration down: %v", err)
|
|
|
|
|
}
|
2026-07-29 14:54:58 +08:00
|
|
|
if err := runner.Down(ctx); err != nil {
|
|
|
|
|
t.Fatalf("metadata migration down: %v", err)
|
|
|
|
|
}
|
2026-07-29 00:26:05 +08:00
|
|
|
if err := runner.Down(ctx); err != nil {
|
|
|
|
|
t.Fatalf("date sync migration down: %v", err)
|
|
|
|
|
}
|
2026-07-28 23:51:59 +08:00
|
|
|
if err := runner.Down(ctx); err == nil {
|
|
|
|
|
t.Fatal("procurement migration down succeeded with retained requests")
|
|
|
|
|
}
|
|
|
|
|
var retained int
|
|
|
|
|
if err := db.QueryRow(
|
|
|
|
|
`SELECT count(*) FROM procurement_requests`,
|
|
|
|
|
).Scan(&retained); err != nil || retained != 2 {
|
|
|
|
|
t.Fatalf("retained requests = %d, error = %v", retained, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestProcurementBlocksInvalidSourceAndChangedRequest(t *testing.T) {
|
|
|
|
|
db := openDatabase(t)
|
|
|
|
|
store, _ := repository.New(db)
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
now := time.Date(2026, 7, 28, 6, 7, 8, 0, time.UTC)
|
|
|
|
|
userID := uuid(1400)
|
|
|
|
|
seedFreightUser(t, db, userID, now)
|
|
|
|
|
run := freightRun(1401, userID, now)
|
|
|
|
|
createAndStartFreightRun(t, store, run, "blocked-source-1", "3")
|
|
|
|
|
batch := freightBatch(1410, "a", "b")
|
|
|
|
|
batch.Orders[0].Items[0].SKU = ""
|
|
|
|
|
batch.Orders[0].Items[0].CanonicalSHA256 = repeatHex("7")
|
|
|
|
|
batch.Orders[0].CanonicalSHA256 = repeatHex("8")
|
|
|
|
|
if err := store.CompleteFreightSync(
|
|
|
|
|
ctx,
|
|
|
|
|
run,
|
|
|
|
|
batch,
|
|
|
|
|
now.Add(time.Second),
|
|
|
|
|
); err != nil {
|
|
|
|
|
t.Fatalf("CompleteFreightSync() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
orders, _ := store.ListFreightOrders(ctx, "local-admin", 10)
|
|
|
|
|
detail, _ := store.GetFreightOrder(ctx, "local-admin", orders[0].ID)
|
|
|
|
|
service, _ := usecase.NewProcurementService(
|
|
|
|
|
store,
|
|
|
|
|
procurementClock{now: now.Add(2 * time.Second)},
|
|
|
|
|
&procurementIDs{next: 1500},
|
|
|
|
|
)
|
|
|
|
|
blocked, err := service.CreateRequest(
|
|
|
|
|
ctx,
|
|
|
|
|
usecase.CreateProcurementRequestCommand{
|
|
|
|
|
CreatorSubject: "local-admin",
|
|
|
|
|
ActorUserID: userID,
|
|
|
|
|
FreightOrderItemID: detail.Items[0].ID,
|
|
|
|
|
ConfirmProcurementNeeded: true,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if err != nil || blocked.Request.Status != domain.ProcurementBlocked ||
|
|
|
|
|
blocked.Request.BlockingCode == nil ||
|
|
|
|
|
*blocked.Request.BlockingCode != domain.ProcurementBlockSKURequired {
|
|
|
|
|
t.Fatalf("blocked request = %+v, %v", blocked, err)
|
|
|
|
|
}
|
|
|
|
|
_, err = service.CreateTask(
|
|
|
|
|
ctx,
|
|
|
|
|
usecase.CreateProcurementTaskCommand{
|
|
|
|
|
CreatorSubject: "local-admin",
|
|
|
|
|
ActorUserID: userID,
|
|
|
|
|
RequestID: blocked.Request.ID,
|
|
|
|
|
IdempotencyKey: "blocked-task",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
var typed *usecase.Error
|
|
|
|
|
if !errors.As(err, &typed) ||
|
|
|
|
|
typed.Code != "PROCUREMENT_STATE_CONFLICT" {
|
|
|
|
|
t.Fatalf("blocked CreateTask() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type procurementClock struct {
|
|
|
|
|
now time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (clock procurementClock) Now() time.Time {
|
|
|
|
|
return clock.now
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type procurementIDs struct {
|
|
|
|
|
next int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (ids *procurementIDs) NewID() (string, error) {
|
|
|
|
|
ids.next++
|
|
|
|
|
return uuid(ids.next), nil
|
|
|
|
|
}
|