feat(t218): reconcile single order submissions
This commit is contained in:
@@ -713,6 +713,9 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() after review error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("order submission migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("order dry-run migration down: %v", err)
|
||||
}
|
||||
@@ -724,8 +727,8 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
}
|
||||
if applied, err := runner.Up(context.Background()); err != nil {
|
||||
t.Fatalf("restore device command migration: %v", err)
|
||||
} else if applied != 2 {
|
||||
t.Fatalf("restored migrations = %d, want 2", applied)
|
||||
} else if applied != 3 {
|
||||
t.Fatalf("restored migrations = %d, want 3", applied)
|
||||
}
|
||||
|
||||
completePayload := fmt.Sprintf(
|
||||
@@ -1125,6 +1128,12 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
||||
if !strings.Contains(readyDryRun.Body.String(), `"status":"READY"`) {
|
||||
t.Fatalf("dry-run ready = %s", readyDryRun.Body.String())
|
||||
}
|
||||
var readyDryRunResponse struct {
|
||||
DryRun struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"dry_run"`
|
||||
}
|
||||
decodeResponse(t, readyDryRun, &readyDryRunResponse)
|
||||
readyDryRunReplay := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID +
|
||||
@@ -1139,12 +1148,182 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
||||
if !strings.Contains(readyDryRunReplay.Body.String(), `"replayed":true`) {
|
||||
t.Fatalf("dry-run ready replay = %s", readyDryRunReplay.Body.String())
|
||||
}
|
||||
var deliveredEvents, acknowledgedEvents, dryRunStartedEvents, dryRunReadyEvents int
|
||||
startSubmissionPayload := fmt.Sprintf(
|
||||
`{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_id":%q,"command_sha256":%q,"dry_run_id":%q,"dry_run_evidence_sha256":%q,"observed_title":%q,"selected_sku":%q,"quantity":2,"unit_price_cents":2150,"total_price_cents":4300}`,
|
||||
deviceTestDeviceID,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
command.ID,
|
||||
command.CommandSHA256,
|
||||
readyDryRunResponse.DryRun.ID,
|
||||
dryRunEvidenceSHA,
|
||||
command.Candidate.Title,
|
||||
command.OriginalSKU,
|
||||
)
|
||||
startSubmission := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/order-submissions/start",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(startSubmissionPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-submission-start",
|
||||
})
|
||||
requireDeviceStatus(t, startSubmission, http.StatusOK)
|
||||
var submissionResponse struct {
|
||||
Submission struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"submission"`
|
||||
}
|
||||
decodeResponse(t, startSubmission, &submissionResponse)
|
||||
if submissionResponse.Submission.ID == "" ||
|
||||
!strings.Contains(startSubmission.Body.String(), `"status":"FENCED"`) {
|
||||
t.Fatalf("submission start = %s", startSubmission.Body.String())
|
||||
}
|
||||
startSubmissionReplay := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/order-submissions/start",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(startSubmissionPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-submission-start",
|
||||
})
|
||||
requireDeviceStatus(t, startSubmissionReplay, http.StatusOK)
|
||||
if !strings.Contains(startSubmissionReplay.Body.String(), `"replayed":true`) {
|
||||
t.Fatalf("submission start replay = %s", startSubmissionReplay.Body.String())
|
||||
}
|
||||
secondSubmission := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/order-submissions/start",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(startSubmissionPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-submission-start-second",
|
||||
})
|
||||
requireDeviceStatus(t, secondSubmission, http.StatusConflict)
|
||||
manualReviewPayload := fmt.Sprintf(
|
||||
`{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_id":%q,"command_sha256":%q,"reason_code":"ORDER_PAGE_UNKNOWN"}`,
|
||||
deviceTestDeviceID,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
command.ID,
|
||||
command.CommandSHA256,
|
||||
)
|
||||
manualReview := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/order-submissions/" +
|
||||
submissionResponse.Submission.ID + "/manual-review",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(manualReviewPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-submission-manual",
|
||||
})
|
||||
requireDeviceStatus(t, manualReview, http.StatusOK)
|
||||
if !strings.Contains(manualReview.Body.String(), `"status":"MANUAL_REVIEW"`) {
|
||||
t.Fatalf("submission manual review = %s", manualReview.Body.String())
|
||||
}
|
||||
const reconciliationEvidenceID = "00000000-0000-4000-8000-000000000078"
|
||||
reconciliationEvidenceSHA := strings.Repeat("d", 64)
|
||||
if _, err := fixture.db.Exec(
|
||||
`INSERT INTO execution_evidence_assets (
|
||||
id, task_id, execution_id, media_type, size_bytes, sha256,
|
||||
storage_key, created_at, received_after_execution_expiry
|
||||
) VALUES (?, ?, ?, 'image/jpeg', 10, ?, ?, ?, 0)`,
|
||||
reconciliationEvidenceID,
|
||||
taskID,
|
||||
started.Execution.ID,
|
||||
reconciliationEvidenceSHA,
|
||||
"orders/reconciliation.jpg",
|
||||
time.Now().UTC().Format(time.RFC3339Nano),
|
||||
); err != nil {
|
||||
t.Fatalf("seed reconciliation evidence: %v", err)
|
||||
}
|
||||
reconcilePayload := fmt.Sprintf(
|
||||
`{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_id":%q,"command_sha256":%q,"platform_order_no":"12345678901234567890","platform_ordered_at":%q,"platform_order_status":"PENDING_PAYMENT","observed_title":%q,"selected_sku":%q,"quantity":2,"total_price_cents":4300,"evidence_asset_id":%q,"evidence_sha256":%q}`,
|
||||
deviceTestDeviceID,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
command.ID,
|
||||
command.CommandSHA256,
|
||||
time.Now().UTC().Format(time.RFC3339),
|
||||
command.Candidate.Title,
|
||||
command.OriginalSKU,
|
||||
reconciliationEvidenceID,
|
||||
reconciliationEvidenceSHA,
|
||||
)
|
||||
reconciled := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/order-submissions/" +
|
||||
submissionResponse.Submission.ID + "/reconcile",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(reconcilePayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-submission-reconcile",
|
||||
})
|
||||
requireDeviceStatus(t, reconciled, http.StatusOK)
|
||||
if !strings.Contains(reconciled.Body.String(), `"status":"RECONCILED"`) ||
|
||||
!strings.Contains(reconciled.Body.String(), `"platform_order_status":"PENDING_PAYMENT"`) {
|
||||
t.Fatalf("submission reconciliation = %s", reconciled.Body.String())
|
||||
}
|
||||
reconciledReplay := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/order-submissions/" +
|
||||
submissionResponse.Submission.ID + "/reconcile",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(reconcilePayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-submission-reconcile",
|
||||
})
|
||||
requireDeviceStatus(t, reconciledReplay, http.StatusOK)
|
||||
if !strings.Contains(reconciledReplay.Body.String(), `"replayed":true`) {
|
||||
t.Fatalf("submission reconcile replay = %s", reconciledReplay.Body.String())
|
||||
}
|
||||
var outcomeSubmitted bool
|
||||
if err := fixture.db.QueryRow(
|
||||
`SELECT order_submitted FROM execution_outcomes
|
||||
WHERE execution_id = ?`,
|
||||
started.Execution.ID,
|
||||
).Scan(&outcomeSubmitted); err != nil {
|
||||
t.Fatalf("query reconciled outcome: %v", err)
|
||||
}
|
||||
if !outcomeSubmitted {
|
||||
t.Fatal("reconciled execution outcome did not record order_submitted")
|
||||
}
|
||||
var taskStatus, authorizationStatus string
|
||||
if err := fixture.db.QueryRow(
|
||||
`SELECT status FROM purchase_tasks WHERE id = ?`,
|
||||
taskID,
|
||||
).Scan(&taskStatus); err != nil {
|
||||
t.Fatalf("query reconciled task: %v", err)
|
||||
}
|
||||
if err := fixture.db.QueryRow(
|
||||
`SELECT status FROM order_authorizations WHERE id = ?`,
|
||||
command.ID,
|
||||
).Scan(&authorizationStatus); err != nil {
|
||||
t.Fatalf("query consumed authorization: %v", err)
|
||||
}
|
||||
if taskStatus != "SUCCEEDED" || authorizationStatus != "CONSUMED" {
|
||||
t.Fatalf(
|
||||
"reconciled task/authorization = %s/%s",
|
||||
taskStatus,
|
||||
authorizationStatus,
|
||||
)
|
||||
}
|
||||
var deliveredEvents, acknowledgedEvents, dryRunStartedEvents,
|
||||
dryRunReadyEvents, fencedEvents, manualEvents, reconciledEvents int
|
||||
for eventType, target := range map[string]*int{
|
||||
"ORDER_AUTHORIZATION_DELIVERED": &deliveredEvents,
|
||||
"ORDER_AUTHORIZATION_ACKNOWLEDGED": &acknowledgedEvents,
|
||||
"ORDER_DRY_RUN_STARTED": &dryRunStartedEvents,
|
||||
"ORDER_DRY_RUN_READY": &dryRunReadyEvents,
|
||||
"ORDER_SUBMISSION_FENCED": &fencedEvents,
|
||||
"ORDER_SUBMISSION_MANUAL_REVIEW": &manualEvents,
|
||||
"ORDER_SUBMISSION_RECONCILED": &reconciledEvents,
|
||||
} {
|
||||
if err := fixture.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM task_events
|
||||
@@ -1156,13 +1335,17 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
||||
}
|
||||
}
|
||||
if deliveredEvents != 1 || acknowledgedEvents != 1 ||
|
||||
dryRunStartedEvents != 1 || dryRunReadyEvents != 1 {
|
||||
dryRunStartedEvents != 1 || dryRunReadyEvents != 1 ||
|
||||
fencedEvents != 1 || manualEvents != 1 || reconciledEvents != 1 {
|
||||
t.Fatalf(
|
||||
"delivery/ack/dry-run events = %d/%d/%d/%d",
|
||||
"order workflow events = %d/%d/%d/%d/%d/%d/%d",
|
||||
deliveredEvents,
|
||||
acknowledgedEvents,
|
||||
dryRunStartedEvents,
|
||||
dryRunReadyEvents,
|
||||
fencedEvents,
|
||||
manualEvents,
|
||||
reconciledEvents,
|
||||
)
|
||||
}
|
||||
runner, err := migration.New(fixture.db)
|
||||
@@ -1170,7 +1353,7 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err == nil {
|
||||
t.Fatal("order dry-run migration down succeeded with dry-run data")
|
||||
t.Fatal("order submission migration down succeeded with retained data")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1503,13 +1686,18 @@ func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture {
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewOrderDryRunService() error = %v", err)
|
||||
}
|
||||
submissions, err := usecase.NewOrderSubmissionService(store, clock, ids)
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewOrderSubmissionService() error = %v", err)
|
||||
}
|
||||
deviceRoutes, err := NewDeviceRouteRegistrar(
|
||||
DeviceServices{
|
||||
Lifecycle: lifecycle,
|
||||
Assets: assets,
|
||||
Results: results,
|
||||
Commands: commands,
|
||||
DryRuns: dryRuns,
|
||||
Lifecycle: lifecycle,
|
||||
Assets: assets,
|
||||
Results: results,
|
||||
Commands: commands,
|
||||
DryRuns: dryRuns,
|
||||
Submissions: submissions,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user