@rdlabo/workers-hono-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +247 -0
  3. package/dist/ai/gateway.d.ts +40 -0
  4. package/dist/ai/gateway.js +36 -0
  5. package/dist/aws/cloudfront.d.ts +9 -0
  6. package/dist/aws/cloudfront.js +43 -0
  7. package/dist/aws/secrets-manager.d.ts +14 -0
  8. package/dist/aws/secrets-manager.js +43 -0
  9. package/dist/cache/kv-cache.d.ts +51 -0
  10. package/dist/cache/kv-cache.js +88 -0
  11. package/dist/db/connection.d.ts +41 -0
  12. package/dist/db/connection.js +48 -0
  13. package/dist/db/database.d.ts +60 -0
  14. package/dist/db/database.js +63 -0
  15. package/dist/db/index.d.ts +10 -0
  16. package/dist/db/index.js +8 -0
  17. package/dist/db/jst.d.ts +19 -0
  18. package/dist/db/jst.js +48 -0
  19. package/dist/db/orm-config.d.ts +62 -0
  20. package/dist/db/orm-config.js +42 -0
  21. package/dist/db/retry.d.ts +6 -0
  22. package/dist/db/retry.js +22 -0
  23. package/dist/db/write-result.d.ts +16 -0
  24. package/dist/db/write-result.js +13 -0
  25. package/dist/firebase/firebase-verifier.d.ts +17 -0
  26. package/dist/firebase/firebase-verifier.js +1 -0
  27. package/dist/firebase/identity-toolkit.d.ts +24 -0
  28. package/dist/firebase/identity-toolkit.js +64 -0
  29. package/dist/firebase/jose-firebase-verifier.d.ts +32 -0
  30. package/dist/firebase/jose-firebase-verifier.js +52 -0
  31. package/dist/firebase/remote-verifier.d.ts +9 -0
  32. package/dist/firebase/remote-verifier.js +44 -0
  33. package/dist/http/app-env.d.ts +19 -0
  34. package/dist/http/app-env.js +16 -0
  35. package/dist/http/app-info.d.ts +11 -0
  36. package/dist/http/app-info.js +8 -0
  37. package/dist/http/http-status.d.ts +62 -0
  38. package/dist/http/http-status.js +63 -0
  39. package/dist/http/nest-error.d.ts +72 -0
  40. package/dist/http/nest-error.js +65 -0
  41. package/dist/http/user-protocol.d.ts +11 -0
  42. package/dist/http/user-protocol.js +8 -0
  43. package/dist/index.d.ts +30 -0
  44. package/dist/index.js +28 -0
  45. package/dist/middleware/auth.d.ts +36 -0
  46. package/dist/middleware/auth.js +30 -0
  47. package/dist/middleware/finalize-response.d.ts +2 -0
  48. package/dist/middleware/finalize-response.js +53 -0
  49. package/dist/middleware/validation.d.ts +77 -0
  50. package/dist/middleware/validation.js +53 -0
  51. package/dist/middleware/zod-coerce.d.ts +9 -0
  52. package/dist/middleware/zod-coerce.js +50 -0
  53. package/dist/stripe/client.d.ts +19 -0
  54. package/dist/stripe/client.js +23 -0
  55. package/dist/testing/auth.d.ts +36 -0
  56. package/dist/testing/auth.js +42 -0
  57. package/dist/testing/configurable-fake.d.ts +14 -0
  58. package/dist/testing/configurable-fake.js +33 -0
  59. package/dist/testing/db.d.ts +37 -0
  60. package/dist/testing/db.js +74 -0
  61. package/dist/testing/fakes.d.ts +35 -0
  62. package/dist/testing/fakes.js +56 -0
  63. package/dist/testing/index.d.ts +8 -0
  64. package/dist/testing/index.js +10 -0
  65. package/dist/testing/stripe-fixtures.d.ts +13 -0
  66. package/dist/testing/stripe-fixtures.js +76 -0
  67. package/package.json +113 -0
  68. package/scripts/sync-dev-aws.mjs +59 -0
  69. package/src/ai/gateway.ts +81 -0
  70. package/src/aws/cloudfront.ts +65 -0
  71. package/src/aws/secrets-manager.ts +63 -0
  72. package/src/cache/kv-cache.ts +134 -0
  73. package/src/db/connection.ts +73 -0
  74. package/src/db/database.ts +133 -0
  75. package/src/db/index.ts +27 -0
  76. package/src/db/jst.ts +56 -0
  77. package/src/db/orm-config.ts +71 -0
  78. package/src/db/retry.ts +21 -0
  79. package/src/db/write-result.ts +23 -0
  80. package/src/firebase/firebase-verifier.ts +15 -0
  81. package/src/firebase/identity-toolkit.ts +82 -0
  82. package/src/firebase/jose-firebase-verifier.ts +71 -0
  83. package/src/firebase/remote-verifier.ts +49 -0
  84. package/src/http/app-env.ts +20 -0
  85. package/src/http/app-info.ts +16 -0
  86. package/src/http/http-status.ts +62 -0
  87. package/src/http/nest-error.ts +138 -0
  88. package/src/http/user-protocol.ts +16 -0
  89. package/src/index.ts +55 -0
  90. package/src/middleware/auth.ts +67 -0
  91. package/src/middleware/finalize-response.ts +61 -0
  92. package/src/middleware/validation.ts +84 -0
  93. package/src/middleware/zod-coerce.ts +65 -0
  94. package/src/stripe/client.ts +48 -0
  95. package/src/testing/auth.ts +62 -0
  96. package/src/testing/configurable-fake.ts +33 -0
  97. package/src/testing/db.ts +125 -0
  98. package/src/testing/fakes.ts +75 -0
  99. package/src/testing/index.ts +26 -0
  100. package/src/testing/stripe-fixtures.ts +85 -0
@@ -0,0 +1,77 @@
1
+ import type { Context } from 'hono';
2
+ import type { ZodType } from 'zod';
3
+ /** zod v3(ZodError) / v4(core $ZodError) どちらの error でも受けられる最小形 */
4
+ export interface ZodErrorLike {
5
+ issues: readonly {
6
+ path: PropertyKey[];
7
+ message: string;
8
+ }[];
9
+ }
10
+ export type ValidationTarget = 'json' | 'query' | 'param' | 'header' | 'cookie' | 'form';
11
+ export interface ValidateOptions {
12
+ /**
13
+ * 検証失敗時のフック(Sentry 通報など)。**検証の挙動は変えない** — レスポンスは常に
14
+ * NestJS ValidationPipe 同形の 400 を返す。例外を投げても握り潰す。
15
+ * 既定は no-op(receptray 互換 = 4xx を通報しない)。foodlabel は Sentry 実通報を差し込む。
16
+ */
17
+ onValidationError?: (error: ZodErrorLike, c: Context) => void;
18
+ }
19
+ export declare function validate<T>(target: ValidationTarget, schema: ZodType<T>, options?: ValidateOptions): import("hono").MiddlewareHandler<import("hono").Env, string, {
20
+ in: {
21
+ json?: unknown;
22
+ query?: {} | undefined;
23
+ param?: {} | undefined;
24
+ header?: {} | undefined;
25
+ cookie?: {} | undefined;
26
+ form?: {} | undefined;
27
+ };
28
+ out: {
29
+ json: T;
30
+ query: T;
31
+ param: T;
32
+ header: T;
33
+ cookie: T;
34
+ form: T;
35
+ };
36
+ }, Response & import("hono").TypedResponse<{
37
+ statusCode: number;
38
+ message: string[];
39
+ error: string;
40
+ }, 400, "json">>;
41
+ /** Sentry の最小形(`@sentry/cloudflare` 等への直接依存を避けるための構造型)。 */
42
+ export interface SentryScopeLike {
43
+ setTag(key: string, value: string): void;
44
+ setContext(key: string, context: Record<string, unknown> | null): void;
45
+ }
46
+ export interface SentryLike {
47
+ withScope(callback: (scope: SentryScopeLike) => void): void;
48
+ captureException(error: unknown): void;
49
+ }
50
+ /**
51
+ * DTO 検証 400 を Sentry に通報する `validate` を返すファクトリ(foodlabel/tipsys/winecode で重複していた
52
+ * reportValidationToSentry を集約)。kit は `@sentry/cloudflare` に依存せず、Sentry モジュールを注入する。
53
+ * 通報は `tag error.type=dto_validation` + `context validation={errorCount, errors}`(各 /api の
54
+ * sentry-validation.pipe.ts 準拠)。検証の挙動・レスポンスは `validate` と同一(通報は副作用のみ)。
55
+ */
56
+ export declare function createSentryValidate(sentry: SentryLike): <T>(target: ValidationTarget, schema: ZodType<T>) => import("hono").MiddlewareHandler<import("hono").Env, string, {
57
+ in: {
58
+ json?: unknown;
59
+ query?: {} | undefined;
60
+ param?: {} | undefined;
61
+ header?: {} | undefined;
62
+ cookie?: {} | undefined;
63
+ form?: {} | undefined;
64
+ };
65
+ out: {
66
+ json: T;
67
+ query: T;
68
+ param: T;
69
+ header: T;
70
+ cookie: T;
71
+ form: T;
72
+ };
73
+ }, Response & import("hono").TypedResponse<{
74
+ statusCode: number;
75
+ message: string[];
76
+ error: string;
77
+ }, 400, "json">>;
@@ -0,0 +1,53 @@
1
+ import { zValidator } from '@hono/zod-validator';
2
+ /**
3
+ * Zod 検証ミドルウェア(フリート共通 = receptray/winecode hono と同一仕様)。
4
+ * 失敗時は NestJS の ValidationPipe と同形の body を返す:
5
+ * { statusCode: 400, message: string[], error: 'Bad Request' }
6
+ *
7
+ * NOTE(parity): message の文字列内容は class-validator と Zod で異なる。各 repo の固定 app の
8
+ * 正常系では DTO 検証 400 は発生しない前提(ビジネス 400 は各エンドポイントで HttpError 忠実再現)。
9
+ */
10
+ function zodToMessages(error) {
11
+ return error.issues.map((issue) => {
12
+ const path = issue.path.map(String).join('.');
13
+ return path ? `${path}: ${issue.message}` : issue.message;
14
+ });
15
+ }
16
+ export function validate(target, schema, options) {
17
+ return zValidator(target, schema, (result, c) => {
18
+ if (!result.success) {
19
+ const messages = zodToMessages(result.error);
20
+ // Surface the failing fields in the runtime log. Without this the 400 is
21
+ // invisible in `wrangler dev`/Workers logs (Sentry is the only sink, and
22
+ // it is not visible locally), so a field-level type mismatch — e.g. a
23
+ // string sent to `z.number()` — dies silently and is painful to diagnose.
24
+ // Paths + zod messages only; no request values are logged.
25
+ console.warn(`[validation] ${c.req.method} ${c.req.path} (${target}) → 400: ${messages.join('; ')}`);
26
+ try {
27
+ options?.onValidationError?.(result.error, c);
28
+ }
29
+ catch {
30
+ // Reporting must never change validation error behavior.
31
+ }
32
+ return c.json({ statusCode: 400, message: messages, error: 'Bad Request' }, 400);
33
+ }
34
+ return undefined;
35
+ });
36
+ }
37
+ /**
38
+ * DTO 検証 400 を Sentry に通報する `validate` を返すファクトリ(foodlabel/tipsys/winecode で重複していた
39
+ * reportValidationToSentry を集約)。kit は `@sentry/cloudflare` に依存せず、Sentry モジュールを注入する。
40
+ * 通報は `tag error.type=dto_validation` + `context validation={errorCount, errors}`(各 /api の
41
+ * sentry-validation.pipe.ts 準拠)。検証の挙動・レスポンスは `validate` と同一(通報は副作用のみ)。
42
+ */
43
+ export function createSentryValidate(sentry) {
44
+ const onValidationError = (error) => {
45
+ const messages = zodToMessages(error);
46
+ sentry.withScope((scope) => {
47
+ scope.setTag('error.type', 'dto_validation');
48
+ scope.setContext('validation', { errorCount: messages.length, errors: messages });
49
+ sentry.captureException(error);
50
+ });
51
+ };
52
+ return (target, schema) => validate(target, schema, { onValidationError });
53
+ }
@@ -0,0 +1,9 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * 数値強制スキーマ。`inner` に `z.number().int()` 等を渡して制約を足せる(既定は z.number())。
4
+ * 例: zNum(z.number().int()) で整数必須。
5
+ */
6
+ export declare const zNum: (inner?: z.ZodNumber) => z.ZodType<number>;
7
+ export declare const zNumWithDefault: (defaultValue: number, inner?: z.ZodNumber) => z.ZodType<number>;
8
+ export declare const zNumOptional: (inner?: z.ZodNumber) => z.ZodType<number | undefined>;
9
+ export declare const zNumNullable: (inner?: z.ZodNumber) => z.ZodType<number | null | undefined>;
@@ -0,0 +1,50 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * `/api` の class-transformer @Transform(number-transform.util.ts)を Zod preprocess で忠実再現する。
4
+ * path/query は常に文字列で来るため数値強制が必要。空白のみ文字列は NaN にして後段の数値スキーマで弾く
5
+ * (class-validator の @IsInt/@IsNumber が NaN を拒否する挙動と一致。zod v4 は z.number() が NaN を既定拒否)。
6
+ */
7
+ const isBlankString = (value) => typeof value === 'string' && value.trim() === '';
8
+ // toNumber: 空白文字列 → NaN、それ以外 → Number(value)
9
+ const rawToNumber = (value) => (isBlankString(value) ? Number(undefined) : Number(value));
10
+ // toNumberWithDefault: undefined/'' → default、空白 → NaN、else Number
11
+ const rawToNumberWithDefault = (defaultValue) => (value) => {
12
+ if (value === undefined || value === '') {
13
+ return defaultValue;
14
+ }
15
+ if (isBlankString(value)) {
16
+ return Number(undefined);
17
+ }
18
+ return Number(value);
19
+ };
20
+ // toOptionalNumber: undefined/null/'' → undefined、空白 → NaN、else Number
21
+ const rawToOptionalNumber = (value) => {
22
+ if (value === undefined || value === null || value === '') {
23
+ return undefined;
24
+ }
25
+ if (isBlankString(value)) {
26
+ return Number(undefined);
27
+ }
28
+ return Number(value);
29
+ };
30
+ // toNullableNumber: null/undefined → passthrough、'' → undefined、空白 → NaN、else Number
31
+ const rawToNullableNumber = (value) => {
32
+ if (value === null || value === undefined) {
33
+ return value;
34
+ }
35
+ if (value === '') {
36
+ return undefined;
37
+ }
38
+ if (isBlankString(value)) {
39
+ return Number(undefined);
40
+ }
41
+ return Number(value);
42
+ };
43
+ /**
44
+ * 数値強制スキーマ。`inner` に `z.number().int()` 等を渡して制約を足せる(既定は z.number())。
45
+ * 例: zNum(z.number().int()) で整数必須。
46
+ */
47
+ export const zNum = (inner = z.number()) => z.preprocess(rawToNumber, inner);
48
+ export const zNumWithDefault = (defaultValue, inner = z.number()) => z.preprocess(rawToNumberWithDefault(defaultValue), inner);
49
+ export const zNumOptional = (inner = z.number()) => z.preprocess(rawToOptionalNumber, inner.optional());
50
+ export const zNumNullable = (inner = z.number()) => z.preprocess(rawToNullableNumber, inner.nullish());
@@ -0,0 +1,19 @@
1
+ import Stripe from 'stripe';
2
+ /**
3
+ * Stripe クライアント生成(フリート共通 = receptray/tipsys hono)。Cloudflare Workers には Node の
4
+ * http スタックが無いため、Stripe SDK の fetch ベース HttpClient を使う。
5
+ *
6
+ * `apiVersion` は **任意**: 各 repo の `/api`(NestJS)と挙動を一致させるため、固定したい repo は
7
+ * 渡し(例 tipsys は `'2024-04-10'`)、SDK 既定で良い repo は省く(例 receptray)。
8
+ */
9
+ export interface CreateStripeClientOptions {
10
+ /** 固定する Stripe API バージョン。省略すると SDK 既定。 */
11
+ apiVersion?: string;
12
+ }
13
+ export declare function createStripeClient(secret: string, options?: CreateStripeClientOptions): Stripe;
14
+ /**
15
+ * Webhook 署名の検証。Workers では同期版 `constructEvent` が使えない(SubtleCrypto が非同期)ため
16
+ * `constructEventAsync` + `SubtleCryptoProvider` を使う。`secret` は署名検証には無関係だが、検証用の
17
+ * クライアント生成に必要(API コールはしない)。
18
+ */
19
+ export declare function verifyStripeWebhook(secret: string, webhookSecret: string, payload: string | ArrayBuffer, signature: string): Promise<Stripe.Event>;
@@ -0,0 +1,23 @@
1
+ import Stripe from 'stripe';
2
+ export function createStripeClient(secret, options = {}) {
3
+ if (!secret) {
4
+ throw new Error('Stripe secret is not set');
5
+ }
6
+ const config = { httpClient: Stripe.createFetchHttpClient() };
7
+ if (options.apiVersion) {
8
+ config.apiVersion = options.apiVersion;
9
+ }
10
+ return new Stripe(secret, config);
11
+ }
12
+ /**
13
+ * Webhook 署名の検証。Workers では同期版 `constructEvent` が使えない(SubtleCrypto が非同期)ため
14
+ * `constructEventAsync` + `SubtleCryptoProvider` を使う。`secret` は署名検証には無関係だが、検証用の
15
+ * クライアント生成に必要(API コールはしない)。
16
+ */
17
+ export function verifyStripeWebhook(secret, webhookSecret, payload, signature) {
18
+ if (!webhookSecret) {
19
+ throw new Error('Stripe webhook secret is not set');
20
+ }
21
+ const stripe = createStripeClient(secret);
22
+ return stripe.webhooks.constructEventAsync(payload, signature, webhookSecret, undefined, Stripe.createSubtleCryptoProvider());
23
+ }
@@ -0,0 +1,36 @@
1
+ import type { Pool } from 'mysql2/promise';
2
+ import type { DecodedIdToken } from '../firebase/firebase-verifier';
3
+ import type { FakeFirebaseVerifier } from './fakes';
4
+ /**
5
+ * app interceptor 互換の認証ヘッダ(`x-amz-security-token` + `x-amz-meta-*`)を組む。
6
+ * fleet 全 hono repo の route spec で同形に重複していたものを集約。
7
+ *
8
+ * - `version` は `app_version`(varchar(10)) に入るため 10 文字以内。
9
+ * - `contentType: null` を渡すと content-type を付けない(GET 等)。
10
+ */
11
+ export declare function authHeaders(token: string, opts?: {
12
+ version?: string;
13
+ uuid?: string;
14
+ contentType?: string | null;
15
+ }): Record<string, string>;
16
+ /**
17
+ * fake firebase にトークンを登録するだけの薄いヘルパ(DB を触らない)。戻り値はトークン。
18
+ * `users` テーブル形が repo 固有(例: airlec は email 主)で provisionUser が合わない場合に使う。
19
+ */
20
+ export declare function registerFirebaseToken(firebase: FakeFirebaseVerifier, uid: string, record?: Partial<DecodedIdToken>, token?: string): string;
21
+ /**
22
+ * fake firebase にトークンを登録し、`users(firebase_uid)` 行を用意して userId を返す。
23
+ * `users(id, firebase_uid, agree)` の fleet 共通形を前提(foodlabel/receptray など)。同 uid の
24
+ * 既存行があれば再利用する(冪等)。users テーブル形が異なる repo は registerFirebaseToken + 独自
25
+ * provision を使う。
26
+ */
27
+ export declare function provisionUser(pool: Pool, firebase: FakeFirebaseVerifier, opts: {
28
+ uid: string;
29
+ token?: string;
30
+ agree?: number;
31
+ email?: string;
32
+ }): Promise<{
33
+ userId: number;
34
+ uid: string;
35
+ token: string;
36
+ }>;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * app interceptor 互換の認証ヘッダ(`x-amz-security-token` + `x-amz-meta-*`)を組む。
3
+ * fleet 全 hono repo の route spec で同形に重複していたものを集約。
4
+ *
5
+ * - `version` は `app_version`(varchar(10)) に入るため 10 文字以内。
6
+ * - `contentType: null` を渡すと content-type を付けない(GET 等)。
7
+ */
8
+ export function authHeaders(token, opts = {}) {
9
+ const headers = {
10
+ 'x-amz-security-token': token,
11
+ 'x-amz-meta-version': opts.version ?? '1.0.0',
12
+ 'x-amz-meta-uuid': opts.uuid ?? 'test-uuid',
13
+ };
14
+ if (opts.contentType !== null) {
15
+ headers['content-type'] = opts.contentType ?? 'application/json';
16
+ }
17
+ return headers;
18
+ }
19
+ /**
20
+ * fake firebase にトークンを登録するだけの薄いヘルパ(DB を触らない)。戻り値はトークン。
21
+ * `users` テーブル形が repo 固有(例: airlec は email 主)で provisionUser が合わない場合に使う。
22
+ */
23
+ export function registerFirebaseToken(firebase, uid, record = {}, token = `tok-${uid}`) {
24
+ firebase.register(token, { uid, ...record });
25
+ return token;
26
+ }
27
+ /**
28
+ * fake firebase にトークンを登録し、`users(firebase_uid)` 行を用意して userId を返す。
29
+ * `users(id, firebase_uid, agree)` の fleet 共通形を前提(foodlabel/receptray など)。同 uid の
30
+ * 既存行があれば再利用する(冪等)。users テーブル形が異なる repo は registerFirebaseToken + 独自
31
+ * provision を使う。
32
+ */
33
+ export async function provisionUser(pool, firebase, opts) {
34
+ const token = registerFirebaseToken(firebase, opts.uid, opts.email ? { email: opts.email } : {}, opts.token);
35
+ const [existing] = await pool.query('SELECT id FROM users WHERE firebase_uid = ?', [opts.uid]);
36
+ const rows = existing;
37
+ if (rows.length > 0) {
38
+ return { userId: rows[0].id, uid: opts.uid, token };
39
+ }
40
+ const [res] = await pool.query('INSERT INTO users (agree, firebase_uid) VALUES (?, ?)', [opts.agree ?? 1, opts.uid]);
41
+ return { userId: res.insertId, uid: opts.uid, token };
42
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * 部分実装から test double を作る。設定済みメソッドはそのまま、未設定メソッドを呼ぶと
3
+ * `${name}.${method} not configured` で明示的に失敗する。
4
+ *
5
+ * 各 repo の Fake*Gateway に散っていた「`Partial<impl>` を受け取り、未設定なら throw する手書き
6
+ * クラス」の定型を一本化する。interface がドメインごとに異なる gateway(Stripe 等)でも、これで
7
+ * 1 行で必要メソッドだけ差した fake を作れる:
8
+ *
9
+ * const stripe = configurableFake<StripeGateway>(
10
+ * { listPaymentIntents: async () => fakeApiList([fakePaymentIntent()]) },
11
+ * 'FakeStripeGateway',
12
+ * );
13
+ */
14
+ export declare function configurableFake<T extends object>(impl: Partial<T>, name?: string): T;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 部分実装から test double を作る。設定済みメソッドはそのまま、未設定メソッドを呼ぶと
3
+ * `${name}.${method} not configured` で明示的に失敗する。
4
+ *
5
+ * 各 repo の Fake*Gateway に散っていた「`Partial<impl>` を受け取り、未設定なら throw する手書き
6
+ * クラス」の定型を一本化する。interface がドメインごとに異なる gateway(Stripe 等)でも、これで
7
+ * 1 行で必要メソッドだけ差した fake を作れる:
8
+ *
9
+ * const stripe = configurableFake<StripeGateway>(
10
+ * { listPaymentIntents: async () => fakeApiList([fakePaymentIntent()]) },
11
+ * 'FakeStripeGateway',
12
+ * );
13
+ */
14
+ export function configurableFake(impl, name = 'fake') {
15
+ return new Proxy(impl, {
16
+ get(target, prop) {
17
+ if (prop in target) {
18
+ return target[prop];
19
+ }
20
+ // Promise インターロップ用プロパティには「未設定メソッド」関数を返さない。返すと fake 自身が
21
+ // thenable 扱いされ、誤って await / Promise.resolve した瞬間に then() が呼ばれて throw する罠になる。
22
+ if (prop === 'then' || prop === 'catch' || prop === 'finally') {
23
+ return undefined;
24
+ }
25
+ if (typeof prop === 'string') {
26
+ return () => {
27
+ throw new Error(`${name}.${prop} not configured`);
28
+ };
29
+ }
30
+ return undefined;
31
+ },
32
+ });
33
+ }
@@ -0,0 +1,37 @@
1
+ import type { Pool } from 'mysql2/promise';
2
+ /**
3
+ * フリート共通のテスト DB ヘルパ(各 repo の testing/db.ts を集約)。
4
+ * テストスキーマは「コミット済み Drizzle マイグレーション」を単一ソースとして構築する
5
+ * (手書き schema.sql ではなく `db:generate` 由来の ./drizzle)。
6
+ *
7
+ * Node 専用(vitest 下で実行)。実行時 parity には無関係なテスト基盤。
8
+ */
9
+ export interface TestDbConnection {
10
+ host: string;
11
+ port: number;
12
+ user: string;
13
+ password: string;
14
+ }
15
+ export interface CreateTestDbOptions {
16
+ /** テスト DB 名(例 'tipsys_test')。並列実行で feature 毎に分けたい場合は呼び出し側で TEST_DB を解決して渡す。 */
17
+ dbName: string;
18
+ /** Drizzle マイグレーションフォルダの絶対パス(呼び出し側で `join(here, '..', 'drizzle')` を解決して渡す)。 */
19
+ migrationsFolder: string;
20
+ /** 接続情報。未指定は env(DB_HOST/DB_PORT/DB_USER/DB_PASSWORD)→ 127.0.0.1/3306/root/root。 */
21
+ connection?: Partial<TestDbConnection>;
22
+ }
23
+ export interface TestDb {
24
+ readonly dbName: string;
25
+ readonly connection: TestDbConnection;
26
+ /** DROP/CREATE して Drizzle マイグレーションを適用しスキーマを構築。 */
27
+ resetSchema(): Promise<void>;
28
+ /** テスト DB に繋いだ mysql2 プールを返す(afterAll で pool.end())。 */
29
+ createTestPool(): Pool;
30
+ /** 全テーブルを TRUNCATE(information_schema から動的取得。__drizzle_migrations は除外)。 */
31
+ truncateAll(pool: Pool): Promise<void>;
32
+ /** 1 行 insert する汎用 seed(列名→値)。route spec の fixture 用。 */
33
+ seed(pool: Pool, table: string, row: Record<string, unknown>): Promise<void>;
34
+ /** ローカル MySQL が到達可能か(`describe.skipIf(!(await mysqlReachable()))` のガード用)。 */
35
+ mysqlReachable(): Promise<boolean>;
36
+ }
37
+ export declare function createTestDb(options: CreateTestDbOptions): TestDb;
@@ -0,0 +1,74 @@
1
+ import { drizzle } from 'drizzle-orm/mysql2';
2
+ import { migrate } from 'drizzle-orm/mysql2/migrator';
3
+ import { createConnection, createPool } from 'mysql2/promise';
4
+ function resolveConnection(override) {
5
+ const env = globalThis.process?.env ?? {};
6
+ return {
7
+ host: override?.host ?? env.DB_HOST ?? '127.0.0.1',
8
+ port: override?.port ?? Number(env.DB_PORT ?? '3306'),
9
+ user: override?.user ?? env.DB_USER ?? 'root',
10
+ password: override?.password ?? env.DB_PASSWORD ?? 'root',
11
+ };
12
+ }
13
+ export function createTestDb(options) {
14
+ const { dbName, migrationsFolder } = options;
15
+ const connection = resolveConnection(options.connection);
16
+ return {
17
+ dbName,
18
+ connection,
19
+ async resetSchema() {
20
+ const admin = await createConnection({ ...connection, multipleStatements: true });
21
+ await admin.query(`DROP DATABASE IF EXISTS \`${dbName}\`; CREATE DATABASE \`${dbName}\` DEFAULT CHARACTER SET utf8mb4;`);
22
+ await admin.changeUser({ database: dbName });
23
+ await migrate(drizzle(admin), { migrationsFolder });
24
+ await admin.end();
25
+ },
26
+ createTestPool() {
27
+ // decimalNumbers / timezone mirror the runtime hyperdriveConnectionOptions so specs read
28
+ // DECIMAL columns as numbers and handle datetime in +09:00 (JST), matching production.
29
+ const pool = createPool({
30
+ ...connection,
31
+ database: dbName,
32
+ connectionLimit: 5,
33
+ decimalNumbers: true,
34
+ timezone: '+09:00',
35
+ });
36
+ // Pin ONLY_FULL_GROUP_BY on every pooled connection so GROUP BY violations surface in specs
37
+ // regardless of the server's my.cnf (fleet policy centralized here, not per-repo). CONCAT keeps
38
+ // the server's other sql_mode flags and is harmless if ONLY_FULL_GROUP_BY is already present.
39
+ // mysql2 queues this SET ahead of the consumer's first query on each new physical connection.
40
+ pool.on('connection', (conn) => {
41
+ void conn.query("SET SESSION sql_mode = CONCAT(@@SESSION.sql_mode, ',ONLY_FULL_GROUP_BY')");
42
+ });
43
+ return pool;
44
+ },
45
+ async truncateAll(pool) {
46
+ const [rows] = await pool.query("SELECT table_name AS t FROM information_schema.tables WHERE table_schema = ? AND table_type='BASE TABLE' AND table_name <> '__drizzle_migrations'", [dbName]);
47
+ const tables = rows.map((r) => r.t);
48
+ await pool.query('SET FOREIGN_KEY_CHECKS=0');
49
+ for (const t of tables) {
50
+ await pool.query(`TRUNCATE TABLE \`${t}\``);
51
+ }
52
+ await pool.query('SET FOREIGN_KEY_CHECKS=1');
53
+ },
54
+ async seed(pool, table, row) {
55
+ const cols = Object.keys(row);
56
+ if (cols.length === 0) {
57
+ return;
58
+ }
59
+ const placeholders = cols.map(() => '?').join(', ');
60
+ const columnList = cols.map((c) => `\`${c}\``).join(', ');
61
+ await pool.query(`INSERT INTO \`${table}\` (${columnList}) VALUES (${placeholders})`, Object.values(row));
62
+ },
63
+ async mysqlReachable() {
64
+ try {
65
+ const c = await createConnection({ ...connection });
66
+ await c.end();
67
+ return true;
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ },
73
+ };
74
+ }
@@ -0,0 +1,35 @@
1
+ import type { Pool } from 'mysql2/promise';
2
+ import type { DisposableDatabase } from '../db/database';
3
+ import type { DecodedIdToken, FirebaseVerifier } from '../firebase/firebase-verifier';
4
+ /**
5
+ * オフライン route テスト用の in-memory FirebaseVerifier(4 repo 同一実装を集約)。
6
+ * `register(token, { uid })` で偽 ID を仕込む。
7
+ */
8
+ export declare class FakeFirebaseVerifier implements FirebaseVerifier {
9
+ private readonly tokens;
10
+ readonly deleted: string[];
11
+ register(token: string, record: DecodedIdToken): void;
12
+ verifyIdToken(idToken: string): Promise<DecodedIdToken>;
13
+ getUser(uid: string): Promise<{
14
+ uid: string;
15
+ email?: string;
16
+ } | null>;
17
+ deleteUser(uid: string): Promise<void>;
18
+ }
19
+ export interface CreatePoolDatabaseOptions<TDrizzle> {
20
+ /** テスト用プール(primary/replica 兼用)。 */
21
+ pool: Pool;
22
+ /** 消費側の drizzle-orm で `drizzle(pool, { schema, ... })` を作って渡す。 */
23
+ orm: TDrizzle;
24
+ }
25
+ /**
26
+ * テスト用にプール 1 本を primary/replica 兼用にした Database(foodlabel の PoolDatabase 相当)。
27
+ * `dispose()` はプールを閉じる。orm は消費側が自分の drizzle-orm で作って渡す(型同一性の分離)。
28
+ */
29
+ export declare function createPoolDatabase<TDrizzle>(options: CreatePoolDatabaseOptions<TDrizzle>): DisposableDatabase<TDrizzle>;
30
+ /**
31
+ * DB に触れない route(GET / 等)用の Database スタブ。write/transaction は誤用検知のため throw。
32
+ * dispose は no-op(Hyperdrive/Pool 背面の DisposableDatabase を期待する repo でもそのまま使える)。
33
+ * orm 型は呼び出し側が指定(既定 unknown)。
34
+ */
35
+ export declare function createNoopDatabase<TDrizzle = unknown>(): DisposableDatabase<TDrizzle>;
@@ -0,0 +1,56 @@
1
+ import { databaseFrom } from '../db/database';
2
+ /**
3
+ * オフライン route テスト用の in-memory FirebaseVerifier(4 repo 同一実装を集約)。
4
+ * `register(token, { uid })` で偽 ID を仕込む。
5
+ */
6
+ export class FakeFirebaseVerifier {
7
+ tokens = new Map();
8
+ deleted = [];
9
+ register(token, record) {
10
+ this.tokens.set(token, record);
11
+ }
12
+ async verifyIdToken(idToken) {
13
+ const record = this.tokens.get(idToken);
14
+ if (!record) {
15
+ throw new Error('invalid firebase id token');
16
+ }
17
+ return record;
18
+ }
19
+ async getUser(uid) {
20
+ return { uid };
21
+ }
22
+ async deleteUser(uid) {
23
+ this.deleted.push(uid);
24
+ }
25
+ }
26
+ /**
27
+ * テスト用にプール 1 本を primary/replica 兼用にした Database(foodlabel の PoolDatabase 相当)。
28
+ * `dispose()` はプールを閉じる。orm は消費側が自分の drizzle-orm で作って渡す(型同一性の分離)。
29
+ */
30
+ export function createPoolDatabase(options) {
31
+ const { pool, orm } = options;
32
+ const base = databaseFrom(orm, pool);
33
+ return {
34
+ ...base,
35
+ async dispose() {
36
+ await pool.end();
37
+ },
38
+ };
39
+ }
40
+ /**
41
+ * DB に触れない route(GET / 等)用の Database スタブ。write/transaction は誤用検知のため throw。
42
+ * dispose は no-op(Hyperdrive/Pool 背面の DisposableDatabase を期待する repo でもそのまま使える)。
43
+ * orm 型は呼び出し側が指定(既定 unknown)。
44
+ */
45
+ export function createNoopDatabase() {
46
+ return {
47
+ read: async () => [],
48
+ write: () => {
49
+ throw new Error('noopDatabase.write accessed unexpectedly');
50
+ },
51
+ transaction: () => {
52
+ throw new Error('noopDatabase.transaction accessed unexpectedly');
53
+ },
54
+ dispose: async () => { },
55
+ };
56
+ }
@@ -0,0 +1,8 @@
1
+ export { createTestDb } from './db';
2
+ export type { TestDb, CreateTestDbOptions, TestDbConnection } from './db';
3
+ export { FakeFirebaseVerifier, createPoolDatabase, createNoopDatabase } from './fakes';
4
+ export type { CreatePoolDatabaseOptions } from './fakes';
5
+ export type { Database, DisposableDatabase, QueryRunner, TxOf } from '../db/database';
6
+ export { authHeaders, registerFirebaseToken, provisionUser } from './auth';
7
+ export { configurableFake } from './configurable-fake';
8
+ export { fakeApiList, fakePaymentIntent, fakeStripeEvent, fakeCheckoutSession, fakeCustomer, fakePrice, fakeSubscription, } from './stripe-fixtures';
@@ -0,0 +1,10 @@
1
+ // @rdlabo/workers-hono-kit/testing — フリート共通のテスト基盤(mysql2/drizzle 依存)。
2
+ // 実行時には読み込まれないテスト専用ヘルパ。各 repo の testing/db.ts・fakes.ts を集約。
3
+ export { createTestDb } from './db';
4
+ export { FakeFirebaseVerifier, createPoolDatabase, createNoopDatabase } from './fakes';
5
+ // 認証テストヘルパ(route spec のヘッダ生成・ユーザ provision を集約)。
6
+ export { authHeaders, registerFirebaseToken, provisionUser } from './auth';
7
+ // test double ヘルパ(未設定メソッドで明示 throw する部分実装 fake)。
8
+ export { configurableFake } from './configurable-fake';
9
+ // Stripe オブジェクトの test fixture factory。
10
+ export { fakeApiList, fakePaymentIntent, fakeStripeEvent, fakeCheckoutSession, fakeCustomer, fakePrice, fakeSubscription, } from './stripe-fixtures';
@@ -0,0 +1,13 @@
1
+ import type Stripe from 'stripe';
2
+ /**
3
+ * Stripe オブジェクトの test fixture factory。実 SDK 型は巨大なので、テストが参照する範囲だけを
4
+ * 妥当な既定値で組み、`over` で上書きする(最後に 1 度だけ Stripe 型へキャスト)。fleet 各 repo の
5
+ * 課金テストで同じダミー PaymentIntent/Event/... を手組みしていた重複を集約する。
6
+ */
7
+ export declare function fakeApiList<T>(data: T[], over?: Partial<Stripe.ApiList<T>>): Stripe.ApiList<T>;
8
+ export declare function fakePaymentIntent(over?: Partial<Stripe.PaymentIntent>): Stripe.PaymentIntent;
9
+ export declare function fakeStripeEvent(type: string, dataObject: unknown, over?: Partial<Stripe.Event>): Stripe.Event;
10
+ export declare function fakeCheckoutSession(over?: Partial<Stripe.Checkout.Session>): Stripe.Checkout.Session;
11
+ export declare function fakeCustomer(over?: Partial<Stripe.Customer>): Stripe.Customer;
12
+ export declare function fakePrice(over?: Partial<Stripe.Price>): Stripe.Price;
13
+ export declare function fakeSubscription(over?: Partial<Stripe.Subscription>): Stripe.Subscription;