From 66be19f47b55515768e47c37575e10a5e140c9e4 Mon Sep 17 00:00:00 2001 From: rei <107461411+reiroop@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:28:44 +0900 Subject: [PATCH] =?UTF-8?q?refactor(api):=20Stripe=20SDK=20=E3=81=B8?= =?UTF-8?q?=E3=81=AE=E5=88=B0=E9=81=94=E7=B5=8C=E8=B7=AF=E3=82=92=20Contex?= =?UTF-8?q?t=20=E3=81=AE=20stripe.sdk=20=E3=81=AB=E7=B5=B1=E4=B8=80?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context の stripe を Stripe から { sdk: Stripe } に変え、プロシージャが SDK を取り出す経路を 1 つにする。sdk プロパティで Stripe を返すオブジェクト なら何でも代入できるので、プロシージャを変えずに実体を差し替えられる。 あわせて、変更系ガードのテスト 3 件を強めた。これまでは拒否されたことしか 見ておらず、エラーのコードも、Stripe への書き込みが起きなかったことも 検査していなかった。ガードを書き込みの後ろへ動かす変更や、投げるエラーを 別のものに替える変更を検出できない状態だったので、FORBIDDEN のコードと スタブが呼ばれなかったことの両方を表明する形にした。 apps/web/server/utils/stripe.ts は残しているので、apiVersion の明示的な 指定と SDK の遅延生成・キャッシュは変わらない。 --- apps/web/server/routes/rpc/[...].ts | 6 +++++- packages/api/src/checkout.ts | 2 +- packages/api/src/invoices.test.ts | 19 +++++++++++++++++-- packages/api/src/invoices.ts | 8 ++++---- packages/api/src/orpc.ts | 6 +++++- packages/api/src/prices.test.ts | 17 +++++++++++++++-- packages/api/src/prices.ts | 6 +++--- packages/api/src/products.test.ts | 17 +++++++++++++++-- packages/api/src/products.ts | 6 +++--- packages/api/src/test-utils.ts | 10 ++++++---- 10 files changed, 74 insertions(+), 23 deletions(-) diff --git a/apps/web/server/routes/rpc/[...].ts b/apps/web/server/routes/rpc/[...].ts index e28ed12..5a5b89c 100644 --- a/apps/web/server/routes/rpc/[...].ts +++ b/apps/web/server/routes/rpc/[...].ts @@ -14,8 +14,12 @@ export default defineEventHandler(async (event) => { get db() { return useDatabase() }, + // このゲッターは参照のたびに新しい { sdk } を作る。SDK 本体は useStripe が + // モジュールスコープでキャッシュするので、包みを作り直しても SDK は 1 つのままである。 + // 実体をインスタンス単位でキャッシュを持つものに替える場合は、参照のたびに新しい + // インスタンスが生成されるので、包みをモジュールスコープへ持ち上げてから替えること。 get stripe() { - return useStripe() + return { sdk: useStripe() } }, // 変更系ガードのフラグ。既定 false で無認証の書き込みを塞ぐ(#15 で認可に置き換え)。 mutationsEnabled: useRuntimeConfig().enableUnsafeMutations, diff --git a/packages/api/src/checkout.ts b/packages/api/src/checkout.ts index bf70c87..92af075 100644 --- a/packages/api/src/checkout.ts +++ b/packages/api/src/checkout.ts @@ -28,7 +28,7 @@ export const checkoutRouter = { if (input.limit !== undefined) params.limit = input.limit if (input.startingAfter !== undefined) params.starting_after = input.startingAfter - const page = await context.stripe.checkout.sessions.list(params) + const page = await context.stripe.sdk.checkout.sessions.list(params) return toListResponse(page, toCheckoutSessionView) }), }, diff --git a/packages/api/src/invoices.test.ts b/packages/api/src/invoices.test.ts index 4b0c14d..80afcaf 100644 --- a/packages/api/src/invoices.test.ts +++ b/packages/api/src/invoices.test.ts @@ -220,7 +220,18 @@ describe('invoices.issue', () => { }) it('mutationsEnabled が false なら作成を拒否する(認可導入までの暫定ガード)', async () => { - const context = testContext({ invoices: { create: () => Promise.resolve({ id: 'in_1' }) } }, false) + let called = false + const context = testContext( + { + invoices: { + create: () => { + called = true + return Promise.resolve({ id: 'in_1' }) + }, + }, + }, + false, + ) await expect( call( @@ -228,7 +239,11 @@ describe('invoices.issue', () => { { customer: 'cus_1', price: 'price_1', daysUntilDue: 14, idempotencyKey: 'idem_1' }, { context }, ), - ).rejects.toThrow() + ).rejects.toThrow(expect.objectContaining({ code: 'FORBIDDEN' })) + // ガードより後ろで Stripe への書き込みが起きていないことを見る(ガードを書き込みの後ろへ動かす変更を検出する)。 + // 記録するのはスタブが供給する invoices.create だけでよい。後続の invoiceItems.create と + // invoices.finalizeInvoice はスタブに無く、到達すれば TypeError になって上の検査が落ちる。 + expect(called).toBe(false) }) // 実装は hosted_invoice_url の null と undefined の両方をエラーにする。両ケースを個別に検証する。 diff --git a/packages/api/src/invoices.ts b/packages/api/src/invoices.ts index 5f6810a..4ea7783 100644 --- a/packages/api/src/invoices.ts +++ b/packages/api/src/invoices.ts @@ -27,7 +27,7 @@ export const invoicesRouter = { if (input.limit !== undefined) params.limit = input.limit if (input.startingAfter !== undefined) params.starting_after = input.startingAfter - const page = await context.stripe.invoices.list(params) + const page = await context.stripe.sdk.invoices.list(params) return toListResponse(page, toInvoiceView) }), @@ -55,9 +55,9 @@ export const invoicesRouter = { days_until_due: input.daysUntilDue, } - const invoice = await context.stripe.invoices.create(params, idem('create')) + const invoice = await context.stripe.sdk.invoices.create(params, idem('create')) - await context.stripe.invoiceItems.create( + await context.stripe.sdk.invoiceItems.create( { customer: input.customer, pricing: { price: input.price }, @@ -66,7 +66,7 @@ export const invoicesRouter = { idem('item'), ) - const finalized = await context.stripe.invoices.finalizeInvoice( + const finalized = await context.stripe.sdk.invoices.finalizeInvoice( invoice.id, undefined, idem('finalize'), diff --git a/packages/api/src/orpc.ts b/packages/api/src/orpc.ts index 3049d9e..e12a958 100644 --- a/packages/api/src/orpc.ts +++ b/packages/api/src/orpc.ts @@ -10,7 +10,11 @@ import { contract } from '@checkin/api-contract' */ export interface Context { db: Database - stripe: Stripe + /** + * Stripe SDK への到達経路。プロシージャは Context の stripe.sdk から SDK を取得する。 + * sdk プロパティで Stripe を返すオブジェクトなら、プロシージャを変えずに実体を差し替えられる。 + */ + stripe: { sdk: Stripe } /** 変更系(作成・更新)を許可するか。方針は project.md、機構は assertMutationsEnabled を参照。 */ mutationsEnabled: boolean } diff --git a/packages/api/src/prices.test.ts b/packages/api/src/prices.test.ts index ed6d846..0a251f5 100644 --- a/packages/api/src/prices.test.ts +++ b/packages/api/src/prices.test.ts @@ -172,10 +172,23 @@ describe('prices.update', () => { }) it('mutationsEnabled が false なら更新を拒否する(認可導入までの暫定ガード)', async () => { - const context = testContext({ prices: { update: () => Promise.resolve(price({})) } }, false) + let called = false + const context = testContext( + { + prices: { + update: () => { + called = true + return Promise.resolve(price({})) + }, + }, + }, + false, + ) await expect( call(appRouter.prices.update, { id: 'price_x', active: false }, { context }), - ).rejects.toThrow() + ).rejects.toThrow(expect.objectContaining({ code: 'FORBIDDEN' })) + // ガードより後ろで Stripe への書き込みが起きていないことを見る(ガードを書き込みの後ろへ動かす変更を検出する)。 + expect(called).toBe(false) }) }) diff --git a/packages/api/src/prices.ts b/packages/api/src/prices.ts index d5a77e0..db2e82f 100644 --- a/packages/api/src/prices.ts +++ b/packages/api/src/prices.ts @@ -20,7 +20,7 @@ function toPriceView(price: Stripe.Price): PriceView { /** 価格プロシージャ — Stripe の Price を Checkin API として公開する。 */ export const pricesRouter = { retrieve: pub.prices.retrieve.handler(async ({ input, context }) => - toPriceView(await context.stripe.prices.retrieve(input.id)), + toPriceView(await context.stripe.sdk.prices.retrieve(input.id)), ), list: pub.prices.list.handler(async ({ input, context }) => { @@ -31,12 +31,12 @@ export const pricesRouter = { if (input.limit !== undefined) params.limit = input.limit if (input.startingAfter !== undefined) params.starting_after = input.startingAfter - const page = await context.stripe.prices.list(params) + const page = await context.stripe.sdk.prices.list(params) return toListResponse(page, toPriceView) }), update: pub.prices.update.handler(async ({ input, context }) => { assertMutationsEnabled(context) - return toPriceView(await context.stripe.prices.update(input.id, { active: input.active })) + return toPriceView(await context.stripe.sdk.prices.update(input.id, { active: input.active })) }), } diff --git a/packages/api/src/products.test.ts b/packages/api/src/products.test.ts index 60b6417..4285996 100644 --- a/packages/api/src/products.test.ts +++ b/packages/api/src/products.test.ts @@ -158,10 +158,23 @@ describe('products.update', () => { }) it('mutationsEnabled が false なら更新を拒否する(認可導入までの暫定ガード)', async () => { - const context = testContext({ products: { update: () => Promise.resolve(product({})) } }, false) + let called = false + const context = testContext( + { + products: { + update: () => { + called = true + return Promise.resolve(product({})) + }, + }, + }, + false, + ) await expect( call(appRouter.products.update, { id: 'prod_x', active: false }, { context }), - ).rejects.toThrow() + ).rejects.toThrow(expect.objectContaining({ code: 'FORBIDDEN' })) + // ガードより後ろで Stripe への書き込みが起きていないことを見る(ガードを書き込みの後ろへ動かす変更を検出する)。 + expect(called).toBe(false) }) }) diff --git a/packages/api/src/products.ts b/packages/api/src/products.ts index f891c6c..ce70ee9 100644 --- a/packages/api/src/products.ts +++ b/packages/api/src/products.ts @@ -18,7 +18,7 @@ function toProductView(product: Stripe.Product): ProductView { /** 商品プロシージャ — Stripe の Product を Checkin API として公開する。 */ export const productsRouter = { retrieve: pub.products.retrieve.handler(async ({ input, context }) => - toProductView(await context.stripe.products.retrieve(input.id)), + toProductView(await context.stripe.sdk.products.retrieve(input.id)), ), list: pub.products.list.handler(async ({ input, context }) => { @@ -27,7 +27,7 @@ export const productsRouter = { if (input.limit !== undefined) params.limit = input.limit if (input.startingAfter !== undefined) params.starting_after = input.startingAfter - const page = await context.stripe.products.list(params) + const page = await context.stripe.sdk.products.list(params) return toListResponse(page, toProductView) }), @@ -39,6 +39,6 @@ export const productsRouter = { if (input.name !== undefined) params.name = input.name if (input.description !== undefined) params.description = input.description - return toProductView(await context.stripe.products.update(input.id, params)) + return toProductView(await context.stripe.sdk.products.update(input.id, params)) }), } diff --git a/packages/api/src/test-utils.ts b/packages/api/src/test-utils.ts index 127bfec..a380940 100644 --- a/packages/api/src/test-utils.ts +++ b/packages/api/src/test-utils.ts @@ -1,13 +1,15 @@ import type { Context } from './orpc' /** - * テスト用の最小 Context。handler が実際に使う stripe リソースだけをスタブして渡す。 - * db は触らせない。mutationsEnabled は既定で true(変更系ガードを通す)。ガード自体を - * 検証するテストでは false を渡す。 + * テスト用の最小 Context。第1引数には、handler が実際に使う Stripe SDK のリソースだけを + * スタブしたオブジェクト(例: { prices: { list } })を、sdk で包まずにそのまま渡す。 + * Context の stripe は { sdk: Stripe } 型なので、包むのはこの関数の側で行い、handler からは + * Context の stripe.sdk 経由で見えるようにする。db は触らせない。mutationsEnabled は既定で + * true(変更系ガードを通す)。ガード自体を検証するテストでは false を渡す。 */ export function testContext(stripe: unknown, mutationsEnabled = true): Context { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- 実際に使う依存だけを供給する意図的なテストスタブ - return { db: {}, stripe, mutationsEnabled } as unknown as Context + return { db: {}, stripe: { sdk: stripe }, mutationsEnabled } as unknown as Context } /**