feat(t223): generate tasks from freight items
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
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.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
|
||||
}
|
||||
Reference in New Issue
Block a user