From 0965615765381b729c6cf2d0383998219b588221 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 22:09:46 -0700 Subject: [PATCH 1/9] test(billing): expose claim-backed plan truth in billing status --- .../billing-effective-plan-status.test.mjs | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/api/billing-effective-plan-status.test.mjs diff --git a/tests/api/billing-effective-plan-status.test.mjs b/tests/api/billing-effective-plan-status.test.mjs new file mode 100644 index 00000000..5c19fac5 --- /dev/null +++ b/tests/api/billing-effective-plan-status.test.mjs @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +delete process.env.ORCHESTRATOR_URL; + +const { app } = await import('../../server/app.mjs'); +const { db } = await import('../../server/db.mjs'); + +const req = (path, opts = {}) => app.request(path, { + ...opts, + headers: { 'content-type': 'application/json', ...(opts.headers || {}) }, +}); +const body = (value) => JSON.stringify(value); + +const signup = await req('/api/auth/signup', { + method: 'POST', + body: body({ email: 'billing-status@example.com', password: 'password123', name: 'Billing status' }), +}); +assert.equal(signup.status, 200, 'signup succeeds'); +const { token } = await signup.json(); +const auth = { authorization: `Bearer ${token}` }; + +const meResponse = await req('/api/me', { headers: auth }); +assert.equal(meResponse.status, 200, 'current user can resolve its workspace'); +const me = await meResponse.json(); +const organizationId = me.orgs[0].id; +assert.equal(me.orgs[0].plan, 'free', 'durable organization plan remains the stored free value'); + +const nowSec = Math.floor(Date.now() / 1000); +const validUntilSec = nowSec + 3600; + +db.prepare(` + INSERT INTO billing_stripe_customers(customer_id, organization_id, first_observed_at_ms) + VALUES(?,?,?) +`).run('cus_status', organizationId, 1); +db.prepare(` + INSERT INTO billing_stripe_subscriptions(subscription_id, customer_id, first_observed_at_ms) + VALUES(?,?,?) +`).run('sub_status', 'cus_status', 1); +const subscriptionObservationId = Number(db.prepare(` + INSERT INTO billing_stripe_subscription_observations( + subscription_id, source_event_id, observed_at_ms, subscription_status, + cancel_at_period_end, current_period_start_sec, current_period_end_sec, + canceled_at_sec, ended_at_sec, trial_end_sec, latest_invoice_id + ) VALUES(?,?,?,?,?,?,?,?,?,?,?) +`).run('sub_status', null, 1, 'active', 0, nowSec - 60, validUntilSec, null, null, null, null).lastInsertRowid); +const grantDecisionId = Number(db.prepare(` + INSERT INTO billing_stripe_entitlement_decisions( + subscription_id, evaluated_subscription_observation_id, evaluated_invoice_observation_id, + previous_decision_id, decision_action, decision_reason, entitled, valid_until_sec, + claim_subscription_observation_id, claim_invoice_observation_id, + evaluated_at_sec, recorded_at_ms + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) +`).run( + 'sub_status', subscriptionObservationId, null, null, 'grant', 'api-status-regression', 1, + validUntilSec, subscriptionObservationId, null, nowSec, Date.now(), +).lastInsertRowid); +db.prepare(` + INSERT INTO billing_stripe_entitlement_claim_heads(subscription_id, decision_id) + VALUES(?,?) +`).run('sub_status', grantDecisionId); + +let billingResponse = await req(`/api/orgs/${organizationId}/billing`, { headers: auth }); +assert.equal(billingResponse.status, 200, 'billing status remains available with a current claim'); +let billing = await billingResponse.json(); +assert.equal(billing.plan, 'pro', 'buyer-visible plan reports the current claim-backed effective plan'); +assert.equal(billing.storedPlan, 'free', 'stored/manual plan remains separately auditable'); +assert.equal(billing.planName, 'Pro'); +assert.equal(billing.limits.projects, null, 'effective Pro limits match authorization behavior'); +assert.equal(billing.limits.members, null, 'effective Pro limits match authorization behavior'); + +const revokeDecisionId = Number(db.prepare(` + INSERT INTO billing_stripe_entitlement_decisions( + subscription_id, evaluated_subscription_observation_id, evaluated_invoice_observation_id, + previous_decision_id, decision_action, decision_reason, entitled, valid_until_sec, + claim_subscription_observation_id, claim_invoice_observation_id, + evaluated_at_sec, recorded_at_ms + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) +`).run( + 'sub_status', subscriptionObservationId, null, grantDecisionId, 'revoke', 'api-status-revoked', 0, + null, subscriptionObservationId, null, nowSec, Date.now(), +).lastInsertRowid); +db.prepare(` + UPDATE billing_stripe_entitlement_claim_heads + SET decision_id = ? + WHERE subscription_id = ? +`).run(revokeDecisionId, 'sub_status'); + +billingResponse = await req(`/api/orgs/${organizationId}/billing`, { headers: auth }); +assert.equal(billingResponse.status, 200, 'billing status remains available after revocation'); +billing = await billingResponse.json(); +assert.equal(billing.plan, 'free', 'revocation immediately returns buyer-visible plan truth to Free'); +assert.equal(billing.storedPlan, 'free'); +assert.equal(billing.planName, 'Free'); +assert.equal(billing.limits.projects, 2); +assert.equal(billing.limits.members, 3); + +db.prepare('UPDATE orgs SET plan = ? WHERE id = ?').run('pro', organizationId); +billingResponse = await req(`/api/orgs/${organizationId}/billing`, { headers: auth }); +billing = await billingResponse.json(); +assert.equal(billing.plan, 'pro', 'explicit manual Pro remains visible independently of Stripe claims'); +assert.equal(billing.storedPlan, 'pro'); +assert.equal(billing.planName, 'Pro'); + +db.close(); From fc1eb9af79fb3eb7336ad9e37f6024b399d7052b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 22:10:25 -0700 Subject: [PATCH 2/9] test(billing): register effective plan status regression --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c7c3cbc1..3f6046b3 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs && node tests/api/stripe-webhook.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs && node tests/api/billing-effective-plan-status.test.mjs && node tests/api/stripe-webhook.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-effective-plan.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/toast-accessibility.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/application_routes.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --include=server/stripe_webhook_event_ledger.mjs --include=server/stripe_subscription_provider.mjs --include=server/stripe_subscription_observation_ledger.mjs --include=server/stripe_subscription_current_projection.mjs --include=server/stripe_entitlement_policy.mjs --include=server/stripe_invoice_provider.mjs --include=server/stripe_invoice_observation_ledger.mjs --include=server/stripe_invoice_current_projection.mjs --include=server/stripe_entitlement_claim_ledger.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-effective-plan.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && npm run test:api", From c613337917019d9b1450879a4e94b95a89575b0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 22:40:14 -0700 Subject: [PATCH 3/9] feat(billing): normalize effective billing status response --- server/billing_status_response.mjs | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 server/billing_status_response.mjs diff --git a/server/billing_status_response.mjs b/server/billing_status_response.mjs new file mode 100644 index 00000000..7ba7c0d2 --- /dev/null +++ b/server/billing_status_response.mjs @@ -0,0 +1,36 @@ +import { PLANS } from './billing.mjs'; + +function planMatchesResponse(plan, payload) { + return plan.name === payload.planName + && plan.priceKrw === payload.priceKrw + && plan.limits.projects === payload.limits?.projects + && plan.limits.members === payload.limits?.members; +} + +/** + * Normalize the public billing-status payload so `plan` always identifies the + * effective authorization plan while `storedPlan` preserves the durable/manual + * organization value for audit and operator diagnosis. + * + * The legacy route already derives `planName`, price, and limits through + * `planOf(org)`. Matching that complete bounded definition against the shared + * `PLANS` catalog avoids trusting a browser-selected claim or inferring paid + * authority from the stored plan alone. + * + * @param {object} payload JSON payload produced by the internal billing route. + * @returns {Readonly} normalized public response payload. + * @throws {TypeError} when the internal payload does not match a known plan. + */ +export function normalizeBillingStatusResponse(payload) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new TypeError('billing status payload must be an object'); + } + const effectivePlan = Object.entries(PLANS).find(([, plan]) => planMatchesResponse(plan, payload)); + if (!effectivePlan) throw new TypeError('billing status payload does not match a known plan'); + + return Object.freeze({ + ...payload, + plan: effectivePlan[0], + storedPlan: payload.plan, + }); +} From 00e60a60c97be8958789a968755c43c5a72a51e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 22:41:02 -0700 Subject: [PATCH 4/9] test(billing): cover effective status normalization --- tests/unit/billing-status-response.test.mjs | 53 +++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/unit/billing-status-response.test.mjs diff --git a/tests/unit/billing-status-response.test.mjs b/tests/unit/billing-status-response.test.mjs new file mode 100644 index 00000000..26bd81b0 --- /dev/null +++ b/tests/unit/billing-status-response.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { normalizeBillingStatusResponse } from '../../server/billing_status_response.mjs'; + +const usage = Object.freeze({ projects: 1, members: 1 }); + +const claimBacked = normalizeBillingStatusResponse({ + plan: 'free', + planName: 'Pro', + priceKrw: 9900, + limits: { projects: null, members: null }, + usage, +}); +assert.equal(claimBacked.plan, 'pro'); +assert.equal(claimBacked.storedPlan, 'free'); +assert.equal(claimBacked.planName, 'Pro'); +assert.equal(claimBacked.usage, usage); +assert.equal(Object.isFrozen(claimBacked), true); + +const free = normalizeBillingStatusResponse({ + plan: 'free', + planName: 'Free', + priceKrw: 0, + limits: { projects: 3, members: 3 }, + usage, +}); +assert.equal(free.plan, 'free'); +assert.equal(free.storedPlan, 'free'); + +const manualPro = normalizeBillingStatusResponse({ + plan: 'pro', + planName: 'Pro', + priceKrw: 9900, + limits: { projects: null, members: null }, + usage, +}); +assert.equal(manualPro.plan, 'pro'); +assert.equal(manualPro.storedPlan, 'pro'); + +assert.throws( + () => normalizeBillingStatusResponse(null), + /billing status payload must be an object/, +); +assert.throws( + () => normalizeBillingStatusResponse({ + plan: 'free', + planName: 'Unknown', + priceKrw: 0, + limits: { projects: 3, members: 3 }, + }), + /billing status payload does not match a known plan/, +); + +console.log('✓ billing status response normalization passed'); From d0de3ca32ea9eb37685c97790aaf01daf33ba974 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 22:41:46 -0700 Subject: [PATCH 5/9] fix(billing): expose effective plan at public status boundary --- server/app.mjs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 3492d48f..01ec59d8 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,6 +1,7 @@ import { Hono } from 'hono'; import { readFile } from 'node:fs/promises'; import { app as applicationRoutes } from './application_routes.mjs'; +import { normalizeBillingStatusResponse } from './billing_status_response.mjs'; const toastStylesheetUrl = new URL('../toast-state.css', import.meta.url); @@ -8,10 +9,9 @@ const toastStylesheetUrl = new URL('../toast-state.css', import.meta.url); * ScopeWeave's public HTTP application entry point. * * The large application route graph remains isolated in - * `application_routes.mjs`. This entry point restores the protected shipped - * toast stylesheet before mounting those routes, so the webhook trust-boundary - * slice cannot delete customer-visible accessibility feedback while it replaces - * the legacy Stripe webhook handler. + * `application_routes.mjs`. This entry point preserves protected public assets + * and normalizes buyer-visible response contracts while the legacy route graph + * is decomposed into dedicated modules. */ export const app = new Hono(); @@ -26,4 +26,24 @@ app.get('/toast-state.css', async (c) => { } }); +// The internal billing route already derives name/price/limits from +// `planOf(org)`, but historically serialized `org.plan` as if it were the same +// authority. Normalize only successful billing responses at the public +// composition boundary so claim-backed authorization and buyer-visible status +// cannot disagree, while preserving the stored/manual value separately. +app.use('/api/orgs/:id/billing', async (c, next) => { + await next(); + if (c.res.status !== 200) return; + + const originalResponse = c.res; + const normalized = normalizeBillingStatusResponse(await originalResponse.clone().json()); + const headers = new Headers(originalResponse.headers); + headers.delete('content-length'); + headers.set('content-type', 'application/json; charset=UTF-8'); + c.res = new Response(JSON.stringify(normalized), { + status: originalResponse.status, + headers, + }); +}); + app.route('/', applicationRoutes); From bc561308da59f1204cfe04a7ed4829519b45a1d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 22:44:09 -0700 Subject: [PATCH 6/9] test(billing): preserve status authorization failures --- tests/api/billing-effective-plan-status.test.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/api/billing-effective-plan-status.test.mjs b/tests/api/billing-effective-plan-status.test.mjs index 5c19fac5..c98f9c2f 100644 --- a/tests/api/billing-effective-plan-status.test.mjs +++ b/tests/api/billing-effective-plan-status.test.mjs @@ -14,6 +14,13 @@ const req = (path, opts = {}) => app.request(path, { }); const body = (value) => JSON.stringify(value); +const unauthorizedBilling = await req('/api/orgs/1/billing'); +assert.equal( + unauthorizedBilling.status, + 401, + 'billing status preserves authorization failures without response normalization', +); + const signup = await req('/api/auth/signup', { method: 'POST', body: body({ email: 'billing-status@example.com', password: 'password123', name: 'Billing status' }), From 0ed0ad042c6a43e21d3576df9f12eedc8877b016 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 22:57:22 -0700 Subject: [PATCH 7/9] test(billing): lock status coverage registration --- .../billing-status-package-contract.test.mjs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/unit/billing-status-package-contract.test.mjs diff --git a/tests/unit/billing-status-package-contract.test.mjs b/tests/unit/billing-status-package-contract.test.mjs new file mode 100644 index 00000000..fafb4a6f --- /dev/null +++ b/tests/unit/billing-status-package-contract.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const packageJson = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), +); +const scripts = packageJson.scripts; + +assert.match( + scripts['test:unit'], + /tests\/unit\/billing-status-response\.test\.mjs/, + 'normal unit CI executes billing-status normalization regressions', +); +assert.match( + scripts['test:coverage'], + /--include=server\/billing_status_response\.mjs(?:\s|$)/, + 'owned billing-status normalization is included in c8 production coverage', +); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/billing-status-response\.test\.mjs/, + 'billing-status normalization regressions execute under c8', +); +assert.match( + scripts['test:unit'], + /tests\/unit\/billing-status-package-contract\.test\.mjs/, + 'normal unit CI executes the billing-status coverage-registration contract itself', +); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/billing-status-package-contract\.test\.mjs/, + 'canonical c8 cases execute the billing-status coverage-registration contract itself', +); + +console.log('✓ billing status package/coverage contract passed'); From 5730be97a7e370ba9dc11db127f2e524ac17f5c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 22:58:03 -0700 Subject: [PATCH 8/9] fix(billing): include status normalizer in owned coverage --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 3f6046b3..dd8a613f 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs && node tests/api/billing-effective-plan-status.test.mjs && node tests/api/stripe-webhook.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-effective-plan.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/toast-accessibility.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/application_routes.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --include=server/stripe_webhook_event_ledger.mjs --include=server/stripe_subscription_provider.mjs --include=server/stripe_subscription_observation_ledger.mjs --include=server/stripe_subscription_current_projection.mjs --include=server/stripe_entitlement_policy.mjs --include=server/stripe_invoice_provider.mjs --include=server/stripe_invoice_observation_ledger.mjs --include=server/stripe_invoice_current_projection.mjs --include=server/stripe_entitlement_claim_ledger.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-effective-plan.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && npm run test:api", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-effective-plan.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/application_routes.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/billing_status_response.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --include=server/stripe_webhook_event_ledger.mjs --include=server/stripe_subscription_provider.mjs --include=server/stripe_subscription_observation_ledger.mjs --include=server/stripe_subscription_current_projection.mjs --include=server/stripe_entitlement_policy.mjs --include=server/stripe_invoice_provider.mjs --include=server/stripe_invoice_observation_ledger.mjs --include=server/stripe_invoice_current_projection.mjs --include=server/stripe_entitlement_claim_ledger.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-effective-plan.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", From 21b83d8de2cb7b1c2a8d4a13c8e89a053197530a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 23:00:56 -0700 Subject: [PATCH 9/9] test(billing): align status fixtures with plan catalog --- tests/unit/billing-status-response.test.mjs | 63 +++++++++++---------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/tests/unit/billing-status-response.test.mjs b/tests/unit/billing-status-response.test.mjs index 26bd81b0..6a46dcc9 100644 --- a/tests/unit/billing-status-response.test.mjs +++ b/tests/unit/billing-status-response.test.mjs @@ -1,53 +1,58 @@ import assert from 'node:assert/strict'; +import { PLANS } from '../../server/billing.mjs'; import { normalizeBillingStatusResponse } from '../../server/billing_status_response.mjs'; const usage = Object.freeze({ projects: 1, members: 1 }); - -const claimBacked = normalizeBillingStatusResponse({ +const proPayload = (overrides = {}) => ({ plan: 'free', - planName: 'Pro', - priceKrw: 9900, - limits: { projects: null, members: null }, + planName: PLANS.pro.name, + priceKrw: PLANS.pro.priceKrw, + limits: { ...PLANS.pro.limits }, usage, + ...overrides, }); + +const claimBacked = normalizeBillingStatusResponse(proPayload()); assert.equal(claimBacked.plan, 'pro'); assert.equal(claimBacked.storedPlan, 'free'); -assert.equal(claimBacked.planName, 'Pro'); +assert.equal(claimBacked.planName, PLANS.pro.name); +assert.equal(claimBacked.priceKrw, PLANS.pro.priceKrw); assert.equal(claimBacked.usage, usage); assert.equal(Object.isFrozen(claimBacked), true); const free = normalizeBillingStatusResponse({ plan: 'free', - planName: 'Free', - priceKrw: 0, - limits: { projects: 3, members: 3 }, + planName: PLANS.free.name, + priceKrw: PLANS.free.priceKrw, + limits: { ...PLANS.free.limits }, usage, }); assert.equal(free.plan, 'free'); assert.equal(free.storedPlan, 'free'); -const manualPro = normalizeBillingStatusResponse({ - plan: 'pro', - planName: 'Pro', - priceKrw: 9900, - limits: { projects: null, members: null }, - usage, -}); +const manualPro = normalizeBillingStatusResponse(proPayload({ plan: 'pro' })); assert.equal(manualPro.plan, 'pro'); assert.equal(manualPro.storedPlan, 'pro'); -assert.throws( - () => normalizeBillingStatusResponse(null), - /billing status payload must be an object/, -); -assert.throws( - () => normalizeBillingStatusResponse({ - plan: 'free', - planName: 'Unknown', - priceKrw: 0, - limits: { projects: 3, members: 3 }, - }), - /billing status payload does not match a known plan/, -); +for (const invalidPayload of [null, 'not-an-object', []]) { + assert.throws( + () => normalizeBillingStatusResponse(invalidPayload), + /billing status payload must be an object/, + ); +} + +const mismatches = [ + { planName: 'Unknown' }, + { priceKrw: PLANS.pro.priceKrw + 1 }, + { limits: { projects: 1, members: PLANS.pro.limits.members } }, + { limits: { projects: PLANS.pro.limits.projects, members: 1 } }, + { limits: undefined }, +]; +for (const mismatch of mismatches) { + assert.throws( + () => normalizeBillingStatusResponse(proPayload(mismatch)), + /billing status payload does not match a known plan/, + ); +} console.log('✓ billing status response normalization passed');