Files
order_site/apps/backend/src/services/fulfillment/fulfillment-state-service.integration.test.ts
T

369 lines
14 KiB
TypeScript

/**
* 履约状态写入口集成测试(需要已执行完全部迁移的 PostgreSQL)。
*
* FULFILLMENT_STATE_TEST_DATABASE_URL=postgres://... \
* npx tsx --test src/services/fulfillment/fulfillment-state-service.integration.test.ts
*/
import assert from 'node:assert/strict'
import test from 'node:test'
const TEST_DATABASE_URL = String(process.env.FULFILLMENT_STATE_TEST_DATABASE_URL || '').trim()
test(
'履约状态事件覆盖状态写入口、遗留直写,并保持变更幂等',
{ skip: !TEST_DATABASE_URL ? '未设置 FULFILLMENT_STATE_TEST_DATABASE_URL' : false },
async () => {
process.env.DATABASE_URL = TEST_DATABASE_URL
const { closeDb, query } = await import('../../db/client.js')
const {
assignFulfillmentChannelsWithExecutor,
attachFulfillmentConfigurations,
listFulfillmentStateEvents,
runWithFulfillmentStateTransaction,
transitionFulfillmentState,
} = await import('./fulfillment-state-service.js')
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
let orderId = 0
let orderItemId = 0
let manualConfigId = 0
let productId = 0
let profileId = 0
try {
const order = await query<{ id: number }>(
`INSERT INTO orders (
provider, platform, shop_id, shop_name, platform_order_id,
order_status, pay_status, total_amount, paid_at, created_at, updated_at
) VALUES (
'91kaquan', 'kuaishou-91', $1, '状态事件集成测试', $2,
'paid', 'paid', 1000, NOW(), NOW(), NOW()
)
RETURNING id`,
[`ff-state-${suffix}`, `FF-STATE-${suffix}`],
)
orderId = Number(order.rows[0]!.id)
const item = await query<{ id: number }>(
`INSERT INTO order_items (
order_id, sku_code, sku_name, quantity, spec_json,
revenue_amount_fen, revenue_basis, created_at, updated_at
) VALUES ($1, $2, $2, 1, '{}'::jsonb, 1000, 'order_total', NOW(), NOW())
RETURNING id`,
[orderId, `状态事件测试-${suffix}`],
)
orderItemId = Number(item.rows[0]!.id)
const initialShadow = await query<{
route_status: string
carrier_status: string
issue_code: string
}>(
`SELECT route_status, carrier_status, issue_code
FROM fulfillments
WHERE order_item_id = $1`,
[orderItemId],
)
assert.equal(initialShadow.rows[0]?.route_status, 'unrouted')
assert.equal(initialShadow.rows[0]?.issue_code, 'missing_channel')
const pendingDetail = {
internalSku: `状态事件测试-${suffix}`,
channel: 'manual',
carrierIssue: 'manual_config_missing',
}
const first = await transitionFulfillmentState({
orderItemId,
expected: { status: 'pending' },
patch: {
pendingReason: 'no_carrier',
pendingDetail,
},
eventType: 'test_carrier_pending',
actor: { type: 'operator', id: 42 },
reason: 'integration_test',
})
assert.equal(first?.fulfillment_pending_reason, 'no_carrier')
let events = await listFulfillmentStateEvents(orderItemId)
assert.equal(events.length, 1)
assert.equal(events[0]?.event_type, 'test_carrier_pending')
assert.equal(events[0]?.actor_type, 'operator')
assert.equal(events[0]?.actor_id, '42')
assert.equal(events[0]?.reason, 'integration_test')
assert.deepEqual(events[0]?.next_pending_detail, pendingDetail)
await transitionFulfillmentState({
orderItemId,
expected: { status: 'pending' },
patch: {
pendingReason: 'no_carrier',
pendingDetail,
},
eventType: 'test_same_state_replay',
})
events = await listFulfillmentStateEvents(orderItemId)
assert.equal(events.length, 1, '相同状态重复写入不能制造第二条事件')
const stale = await transitionFulfillmentState({
orderItemId,
expected: { status: 'assigned' },
patch: {
channel: 'manual',
status: 'assigned',
},
eventType: 'test_stale_transition',
})
assert.equal(stale, null, '期望状态不一致时必须拒绝写入')
assert.equal((await listFulfillmentStateEvents(orderItemId)).length, 1)
await query(
`UPDATE order_items
SET fulfillment_pending_detail = jsonb_build_object('legacyWrite', TRUE),
updated_at = NOW()
WHERE id = $1`,
[orderItemId],
)
events = await listFulfillmentStateEvents(orderItemId)
assert.equal(events.length, 2, '遗留直写也必须由触发器留痕')
assert.equal(events[0]?.event_type, 'fulfillment_state_changed')
assert.equal(events[0]?.actor_type, 'system')
await query(
`UPDATE order_items
SET fulfillment_pending_detail = jsonb_build_object('legacyWrite', TRUE),
updated_at = NOW()
WHERE id = $1`,
[orderItemId],
)
events = await listFulfillmentStateEvents(orderItemId)
assert.equal(events.length, 2, '无字段变化的更新不能再记事件')
await runWithFulfillmentStateTransaction(async (client) => {
const assigned = await assignFulfillmentChannelsWithExecutor(client, {
assignments: [
{
orderItemId,
channel: 'third_party_voucher',
source: 'auto_mapping',
},
],
eventType: 'test_channel_assigned',
reason: 'integration_test',
})
assert.equal(assigned.length, 1)
assert.equal(assigned[0]?.fulfillment_status, 'assigned')
assert.equal(assigned[0]?.fulfillment_channel, 'third_party_voucher')
})
events = await listFulfillmentStateEvents(orderItemId)
assert.equal(events[0]?.event_type, 'test_channel_assigned')
assert.equal(events[0]?.previous_status, 'pending')
assert.equal(events[0]?.next_status, 'assigned')
assert.equal(events[0]?.previous_channel, null)
assert.equal(events[0]?.next_channel, 'third_party_voucher')
const assignedShadow = await query<{
route_status: string
channel: string
carrier_status: string
issue_code: string
event_count: number
}>(
`SELECT
f.route_status,
f.channel,
f.carrier_status,
f.issue_code,
(
SELECT count(*)::int
FROM fulfillment_state_events event
WHERE event.fulfillment_id = f.id
) AS event_count
FROM fulfillments f
WHERE f.order_item_id = $1`,
[orderItemId],
)
assert.equal(assignedShadow.rows[0]?.route_status, 'configured')
assert.equal(assignedShadow.rows[0]?.channel, 'third_party_voucher')
assert.equal(assignedShadow.rows[0]?.carrier_status, 'not_required')
assert.equal(assignedShadow.rows[0]?.issue_code, '')
assert.equal(Number(assignedShadow.rows[0]?.event_count), 3)
const product = await query<{ id: number }>(
`INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
attributes_json, created_at, updated_at
) VALUES ($1, $2, '测试', '件', 'active', '{}'::jsonb, NOW(), NOW())
RETURNING id`,
[`test:ff-state-${suffix}`, `状态事件商品-${suffix}`],
)
productId = Number(product.rows[0]!.id)
const config = await query<{ id: number }>(
`INSERT INTO manual_fulfillment_configs (
sku_key, canonical_product_id, platform_key, credit_quantity,
cost_mode, enabled, note, created_at, updated_at
) VALUES ($1, $2, 'nanhang', 1, 'fixed', TRUE, 'integration_test', NOW(), NOW())
RETURNING id`,
[`状态事件测试-${suffix}`, productId],
)
manualConfigId = Number(config.rows[0]!.id)
const staleConfig = await attachFulfillmentConfigurations({
mappings: [
{
orderItemId,
configId: manualConfigId,
canonicalProductId: productId,
expectedCanonicalProductId: manualConfigId,
},
],
onlyWhenCanonicalProductNull: true,
})
assert.equal(staleConfig.length, 0, '原商品值已变化时不能覆盖并发结果')
const configured = await attachFulfillmentConfigurations({
mappings: [
{
orderItemId,
configId: manualConfigId,
canonicalProductId: productId,
},
],
expected: { status: 'assigned', channel: 'third_party_voucher' },
onlyWhenConfigNull: true,
onlyWhenCanonicalProductNull: true,
})
assert.equal(configured.length, 1)
assert.equal(Number(configured[0]?.fulfillment_config_id), manualConfigId)
assert.equal(Number(configured[0]?.canonical_product_id), productId)
const profile = await query<{ id: number }>(
`INSERT INTO fulfillment_profiles (
profile_key, name, executor_key, requires_claim, auto_dispatch,
inventory_strategy, config_json, created_at, updated_at
) VALUES ($1, '状态事件任务', 'affiliate_dash', TRUE, FALSE,
'external_platform', '{}'::jsonb, NOW(), NOW())
RETURNING id`,
[`ff-state-profile-${suffix}`],
)
profileId = Number(profile.rows[0]!.id)
await query(
`INSERT INTO fulfillment_tasks (
order_id, order_item_id, unit_index, task_no, provider, platform,
shop_id, shop_name, platform_order_id, profile_id, executor_key,
task_status, attempt_no, is_current, created_at, updated_at
)
SELECT
item.order_id, item.id, 1, $2, source.provider, source.platform,
source.shop_id, source.shop_name, source.platform_order_id, $3, 'affiliate_dash',
'paid', 1, TRUE, NOW(), NOW()
FROM order_items item
JOIN orders source ON source.id = item.order_id
WHERE item.id = $1`,
[orderItemId, `FF-SHADOW-TASK-${suffix}`, profileId],
)
const attempts = await query<{
attempt_type: string
unit_index: number
attempt_no: number
source_id: number
is_current: boolean
}>(
`SELECT attempt_type, unit_index, attempt_no, source_id, is_current
FROM fulfillment_attempts
WHERE fulfillment_id = (
SELECT id FROM fulfillments WHERE order_item_id = $1
)
ORDER BY id`,
[orderItemId],
)
assert.equal(attempts.rows.length, 1)
assert.equal(attempts.rows[0]?.attempt_type, 'task')
assert.equal(attempts.rows[0]?.unit_index, 1)
assert.equal(attempts.rows[0]?.attempt_no, 1)
assert.equal(attempts.rows[0]?.is_current, true)
const shadowDiff = await query<{
fulfillment_state_diffs: number
missing_attempts: number
missing_open_issues: number
unlinked_events: number
}>(
`SELECT
COUNT(*) FILTER (
WHERE ROW(
fulfillment.route_status,
fulfillment.channel,
fulfillment.carrier_status,
fulfillment.current_attempt_type,
fulfillment.current_attempt_source_id,
fulfillment.issue_code,
fulfillment.issue_category
) IS DISTINCT FROM ROW(
CASE WHEN state.route_status = 'configured' THEN 'configured' ELSE 'unrouted' END,
state.intended_channel,
state.carrier_status,
state.current_attempt_type,
NULLIF(state.current_attempt_id, 0),
state.issue_code,
state.issue_category
)
)::int AS fulfillment_state_diffs,
(
SELECT COUNT(*)::int
FROM fulfillment_tasks task
WHERE task.order_item_id = $1
AND NOT EXISTS (
SELECT 1 FROM fulfillment_attempts attempt
WHERE attempt.attempt_type = 'task' AND attempt.source_id = task.id
)
) AS missing_attempts,
COUNT(*) FILTER (
WHERE state.issue_code <> ''
AND NOT EXISTS (
SELECT 1 FROM fulfillment_issues issue
WHERE issue.fulfillment_id = fulfillment.id
AND issue.issue_code = state.issue_code
AND issue.status <> 'resolved'
)
)::int AS missing_open_issues,
(
SELECT COUNT(*)::int
FROM fulfillment_state_events event
WHERE event.order_item_id = $1
AND event.fulfillment_id IS NULL
) AS unlinked_events
FROM v_fulfillment_item_state state
JOIN fulfillments fulfillment ON fulfillment.order_item_id = state.order_item_id
WHERE state.order_item_id = $1`,
[orderItemId],
)
assert.equal(Number(shadowDiff.rows[0]?.fulfillment_state_diffs), 0)
assert.equal(Number(shadowDiff.rows[0]?.missing_attempts), 0)
assert.equal(Number(shadowDiff.rows[0]?.missing_open_issues), 0)
assert.equal(Number(shadowDiff.rows[0]?.unlinked_events), 0)
await assert.rejects(
query(`UPDATE fulfillment_state_events SET reason = 'tampered' WHERE order_item_id = $1`, [
orderItemId,
]),
/append-only/,
)
} finally {
if (orderItemId > 0) {
await query(`DELETE FROM order_items WHERE id = $1`, [orderItemId])
}
if (manualConfigId > 0) {
await query(`DELETE FROM manual_fulfillment_configs WHERE id = $1`, [manualConfigId])
}
if (productId > 0) {
await query(`DELETE FROM product_catalog WHERE id = $1`, [productId])
}
if (profileId > 0) {
await query(`DELETE FROM fulfillment_profiles WHERE id = $1`, [profileId])
}
if (orderId > 0) {
await query(`DELETE FROM orders WHERE id = $1`, [orderId])
}
await closeDb()
}
},
)