diff --git a/package.json b/package.json index 2502b5a2..a5373b4d 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "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: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-effective-plan-bootstrap.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-effective-plan-bootstrap.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: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-effective-plan-bootstrap.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-effective-plan-bootstrap.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", diff --git a/server/app.mjs b/server/app.mjs index 95d1598b..8bae5fee 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import { readFile } from 'node:fs/promises'; import { app as applicationRoutes } from './application_routes.mjs'; import { configureBillingEntitlementDatabase } from './billing.mjs'; +import { normalizeBillingStatusResponse } from './billing_status_response.mjs'; import { db } from './db.mjs'; const toastStylesheetUrl = new URL('../toast-state.css', import.meta.url); @@ -17,10 +18,9 @@ configureBillingEntitlementDatabase(db); * 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(); @@ -35,4 +35,24 @@ app.get('/toast-state.css', async (c) => { } }); +// The internal billing route derives name/price/limits from `planOf(org)` while +// retaining the durable/manual value in its legacy `plan` field. Normalize only +// successful billing responses at the public composition boundary so the +// claim-backed authorization plan is buyer-visible and the stored value remains +// separately auditable. +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); 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, + }); +} 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..c98f9c2f --- /dev/null +++ b/tests/api/billing-effective-plan-status.test.mjs @@ -0,0 +1,114 @@ +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 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' }), +}); +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(); 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'); diff --git a/tests/unit/billing-status-response.test.mjs b/tests/unit/billing-status-response.test.mjs new file mode 100644 index 00000000..6a46dcc9 --- /dev/null +++ b/tests/unit/billing-status-response.test.mjs @@ -0,0 +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 proPayload = (overrides = {}) => ({ + plan: 'free', + 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, 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: 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(proPayload({ plan: 'pro' })); +assert.equal(manualPro.plan, 'pro'); +assert.equal(manualPro.storedPlan, 'pro'); + +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');