d3ro-voice/server/supabase/functions/payple-renew/renewal.test.ts

318 lines
13 KiB
TypeScript

import {
classifyLookupFailure,
decideReplay,
interpretLookup,
type LookupOutcome,
normalizeRenewalCandidate,
type RenewalCandidate,
type RenewalDeps,
type RenewalOperation,
type RenewalPaymentApplication,
type RenewalReservation,
renewalIdempotencyKey,
renewSubscription,
withTimeout,
} from './renewal.ts'
import { toRenewalReservation } from './adapters.ts'
import { PaypleBillingError, PaypleVerificationError, TIER_PRICE } from '../_shared/payple.ts'
function assert(condition: boolean, message: string): asserts condition {
if (!condition) throw new Error(message)
}
const NOW = new Date('2026-09-28T01:00:00.000Z')
const ORIGINAL_ORDER = 'D3RO-20260927100000-user0001-aaaaaaaa'
const NEW_ORDER = 'D3RO-20260928100000-user0001-bbbbbbbb'
const candidate: RenewalCandidate = {
userId: 'user0001-0000-0000-0000-000000000000',
tier: 'pro',
payerId: 'payer-1',
resourceId: 'payer-1',
currentPeriodEnd: '2026-09-27T00:00:00.000Z',
renewalFailures: 0,
}
interface Harness {
deps: RenewalDeps
calls: {
reserved: string[]
charges: string[]
lookups: string[]
marks: Array<{ id: string; state: string }>
failures: string[]
applied: RenewalPaymentApplication[]
}
}
function harness(options: {
reservation: RenewalReservation
operation?: RenewalOperation | null
lookup?: LookupOutcome
charge?: () => Promise<{ orderId: string; total: string; payerId?: string; payTime?: string }>
}): Harness {
const calls: Harness['calls'] = {
reserved: [],
charges: [],
lookups: [],
marks: [],
failures: [],
applied: [],
}
const deps: RenewalDeps = {
now: () => NOW,
newOrderId: () => NEW_ORDER,
store: {
reserveRenewal: (_c, key) => {
calls.reserved.push(key)
return Promise.resolve(options.reservation)
},
getOperation: () => Promise.resolve(options.operation ?? null),
markOperation: (id, state) => {
calls.marks.push({ id, state })
return Promise.resolve(true)
},
recordRenewalFailure: (_c, operationId) => {
calls.failures.push(operationId)
return Promise.resolve(true)
},
applyRenewalPayment: (application) => {
calls.applied.push(application)
return Promise.resolve({ applied: true, duplicate: false })
},
},
gateway: {
charge: (request) => {
calls.charges.push(request.orderId)
return options.charge
? options.charge()
: Promise.resolve({ orderId: request.orderId, total: String(request.amount) })
},
lookup: (orderId) => {
calls.lookups.push(orderId)
return Promise.resolve(options.lookup ?? { kind: 'unknown' })
},
},
}
return { deps, calls }
}
function parkedOperation(state: string, expiresAt: string): RenewalOperation {
return { id: 'op-parked', state, providerOrderId: ORIGINAL_ORDER, expiresAt }
}
const EXPIRED = '2026-09-27T01:15:00.000Z'
const LIVE = '2026-09-28T01:10:00.000Z'
// ── 회귀: 결과 불명 작업이 영구히 건너뛰어지던 버그 ─────────────
Deno.test('replayed external_created renewal past its lease records a failure when Payple has no charge', async () => {
const { deps, calls } = harness({
reservation: { kind: 'replay', operationId: 'op-parked' },
operation: parkedOperation('external_created', EXPIRED),
lookup: { kind: 'not_charged' },
})
const result = await renewSubscription(deps, candidate)
assert(calls.lookups.length === 1 && calls.lookups[0] === ORIGINAL_ORDER, 'looks up the original order')
assert(calls.failures.length === 1 && calls.failures[0] === 'op-parked', 'counts a renewal failure')
assert(calls.charges.length === 0, 'never charges again inside a replay')
assert(calls.applied.length === 0, 'grants nothing')
assert(!result.success && result.error === 'payple_renewal_failed', 'reports the failure')
})
Deno.test('replayed reserved renewal killed mid-run applies the charge Payple confirms', async () => {
const { deps, calls } = harness({
reservation: { kind: 'replay', operationId: 'op-parked' },
operation: parkedOperation('reserved', EXPIRED),
lookup: {
kind: 'charged',
payment: {
orderId: ORIGINAL_ORDER,
total: String(TIER_PRICE.pro),
payerId: 'payer-1',
payTime: '20260927100005',
},
},
})
const result = await renewSubscription(deps, candidate)
assert(result.success && result.orderId === ORIGINAL_ORDER, 'renewal succeeds with the original order')
assert(calls.applied.length === 1, 'applies once')
assert(calls.applied[0].operationId === 'op-parked', 'closes the parked operation')
assert(calls.applied[0].orderId === ORIGINAL_ORDER, 'uses the charged order, not a fresh one')
assert(calls.failures.length === 0, 'no failure recorded')
})
Deno.test('replayed failed renewal is reconciled instead of skipped forever', async () => {
const { deps, calls } = harness({
reservation: { kind: 'replay', operationId: 'op-parked' },
operation: parkedOperation('failed', EXPIRED),
lookup: { kind: 'not_charged' },
})
const result = await renewSubscription(deps, candidate)
assert(calls.failures.length === 1, 'failure counted so the attempt key advances')
assert(result.error === 'payple_renewal_failed', 'reported')
})
Deno.test('replay with a live lease is left to the run that owns it', async () => {
const { deps, calls } = harness({
reservation: { kind: 'replay', operationId: 'op-parked' },
operation: parkedOperation('external_created', LIVE),
lookup: { kind: 'not_charged' },
})
const result = await renewSubscription(deps, candidate)
assert(calls.lookups.length === 0 && calls.failures.length === 0, 'untouched')
assert(result.error === 'renewal_operation_in_progress', 'in progress')
})
Deno.test('replay whose lookup is still inconclusive changes nothing', async () => {
const { deps, calls } = harness({
reservation: { kind: 'replay', operationId: 'op-parked' },
operation: parkedOperation('external_created', EXPIRED),
lookup: { kind: 'unknown' },
})
const result = await renewSubscription(deps, candidate)
assert(calls.failures.length === 0 && calls.applied.length === 0, 'no state change')
assert(result.error === 'renewal_requires_reconciliation', 'flagged')
})
Deno.test('replay confirmed with a wrong amount is not auto-applied', async () => {
const { deps, calls } = harness({
reservation: { kind: 'replay', operationId: 'op-parked' },
operation: parkedOperation('external_created', EXPIRED),
lookup: { kind: 'charged', payment: { orderId: ORIGINAL_ORDER, total: '1' } },
})
const result = await renewSubscription(deps, candidate)
assert(calls.applied.length === 0 && calls.failures.length === 0, 'left for manual review')
assert(result.error === 'renewal_requires_reconciliation', 'flagged')
})
Deno.test('replay of an applied operation is a no-op', async () => {
const { deps, calls } = harness({
reservation: { kind: 'replay', operationId: 'op-parked' },
operation: parkedOperation('applied', EXPIRED),
})
const result = await renewSubscription(deps, candidate)
assert(calls.lookups.length === 0 && calls.applied.length === 0, 'untouched')
assert(result.error === 'idempotent_replay', 'reported as replay')
})
// ── 기존 동작 보존 ─────────────────────────────────────
Deno.test('fresh renewal charges and applies once', async () => {
const { deps, calls } = harness({ reservation: { kind: 'created', operationId: 'op-new' } })
const result = await renewSubscription(deps, candidate)
assert(result.success && result.orderId === NEW_ORDER, 'success')
assert(calls.reserved[0] === renewalIdempotencyKey(candidate), 'uses the attempt key')
assert(calls.marks.some((mark) => mark.state === 'charged'), 'marked charged')
assert(calls.applied.length === 1 && calls.applied[0].operationId === 'op-new', 'applied')
})
Deno.test('unknown charge outcome parks the operation without counting a failure', async () => {
const { deps, calls } = harness({
reservation: { kind: 'created', operationId: 'op-new' },
charge: () => Promise.reject(new PaypleBillingError('payple_billing_transport_unknown', false)),
})
const result = await renewSubscription(deps, candidate)
assert(calls.marks.length === 1 && calls.marks[0].state === 'external_created', 'parked')
assert(calls.failures.length === 0, 'not counted yet')
assert(result.error === 'renewal_requires_reconciliation', 'flagged')
})
Deno.test('definitive decline records a renewal failure', async () => {
const { deps, calls } = harness({
reservation: { kind: 'created', operationId: 'op-new' },
charge: () => Promise.reject(new PaypleBillingError('payple_billing_declined', true)),
})
const result = await renewSubscription(deps, candidate)
assert(calls.failures.length === 1 && calls.failures[0] === 'op-new', 'counted')
assert(result.error === 'payple_renewal_failed', 'reported')
})
Deno.test('rejected reservation reports its reason', async () => {
const { deps, calls } = harness({
reservation: { kind: 'rejected', reason: 'payment_operation_in_progress' },
})
const result = await renewSubscription(deps, candidate)
assert(calls.charges.length === 0, 'no charge')
assert(result.error === 'payment_operation_in_progress', 'reason passed through')
})
// ── 순수 정책 ──────────────────────────────────────────
Deno.test('reservation rows map replays to reconciliation', () => {
const replay = toRenewalReservation({
created: false, operation_id: 'op-1', state: 'external_created', reason: 'idempotent_replay',
})
assert(replay.kind === 'replay' && replay.operationId === 'op-1', 'replay')
const created = toRenewalReservation({ created: true, operation_id: 'op-2' })
assert(created.kind === 'created', 'created')
const rejected = toRenewalReservation({ created: false, reason: 'provider_not_owner' })
assert(rejected.kind === 'rejected' && rejected.reason === 'provider_not_owner', 'rejected')
const empty = toRenewalReservation(null)
assert(empty.kind === 'rejected' && empty.reason === 'renewal_operation_in_progress', 'default')
})
Deno.test('decideReplay only waits on live in-flight leases', () => {
const op = (state: string, expiresAt: string | null): RenewalOperation => ({
id: 'op', state, providerOrderId: ORIGINAL_ORDER, expiresAt,
})
assert(decideReplay(op('reserved', LIVE), NOW) === 'in_progress', 'live reserved')
assert(decideReplay(op('charged', LIVE), NOW) === 'in_progress', 'live charged')
assert(decideReplay(op('reserved', EXPIRED), NOW) === 'reconcile', 'expired reserved')
assert(decideReplay(op('external_created', null), NOW) === 'reconcile', 'no lease')
assert(decideReplay(op('failed', LIVE), NOW) === 'reconcile', 'failed')
assert(decideReplay(op('applied', EXPIRED), NOW) === 'already_applied', 'applied')
})
Deno.test('lookup classification treats only an unconfirmed transaction as not charged', () => {
assert(
classifyLookupFailure(new PaypleVerificationError('payple_lookup_mismatch')) === 'not_charged',
'mismatch',
)
assert(
classifyLookupFailure(new PaypleVerificationError('payple_lookup_http_502')) === 'unknown',
'http',
)
assert(classifyLookupFailure(new TypeError('network')) === 'unknown', 'transport')
assert(
interpretLookup({ PCD_PAY_OID: ORIGINAL_ORDER, PCD_PAY_STATE: '승인취소완료' }).kind === 'not_charged',
'canceled',
)
assert(interpretLookup({ PCD_PAY_OID: ORIGINAL_ORDER, PCD_PAY_TOTAL: '9900' }).kind === 'charged', 'paid')
})
Deno.test('attempt key advances only with recorded failures', () => {
const first = renewalIdempotencyKey(candidate)
assert(first === renewalIdempotencyKey({ ...candidate }), 'stable')
assert(first !== renewalIdempotencyKey({ ...candidate, renewalFailures: 1 }), 'advances')
assert(first.endsWith(':attempt1'), 'attempt number')
})
Deno.test('candidate normalization rejects incomplete rows', () => {
const row = {
user_id: candidate.userId,
tier: 'pro',
payple_payer_id: 'payer-1',
provider_resource_id: 'payer-1',
current_period_end: candidate.currentPeriodEnd,
renewal_failures: 2,
}
assert(normalizeRenewalCandidate(row)?.renewalFailures === 2, 'valid')
assert(normalizeRenewalCandidate({ ...row, tier: 'free' }) === null, 'free tier')
assert(normalizeRenewalCandidate({ ...row, payple_payer_id: null }) === null, 'no payer')
})
Deno.test('withTimeout turns a hung call into the supplied error', async () => {
let error: unknown
try {
await withTimeout(
new Promise<never>(() => {}),
5,
() => new PaypleBillingError('payple_billing_timeout_unknown', false),
)
} catch (caught) {
error = caught
}
assert(error instanceof PaypleBillingError && !error.definitive, 'non-definitive timeout')
assert(await withTimeout(Promise.resolve(7), 1_000, () => new Error('x')) === 7, 'passes through')
})