Files
cmroubao/backend-api/internal/repository/sqlite/procurement_repository_test.go
T

335 lines
9.0 KiB
Go

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)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("image migration down: %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("metadata migration down: %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("date sync migration down: %v", err)
}
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
}