Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 31 additions & 54 deletions modules/hello/test/hello.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,56 +3,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { SignJWT, exportJWK, generateKeyPair } from 'jose';

import app from '../src/index';
import { createModuleDb, type ModuleTestDb } from '../../../packages/module-sdk/test/test-factory';

/**
* #13 测试:假 JWKS(本测内生成 ES256 对,服务 JWKS 端点)+
* 内存假 D1(module_kv 表语义,与 #9 假 D1 同构)。
* 验收链路:SDK 存储读写 hello 计数、跨前缀拒绝由 SDK 层保证(#9 用例),
* 真 SQLite module_kv(#60:假 D1 换真库——modules 统一迁移真建表,SDK 收口由真库裁决)。
* 验收链路:SDK 存储读写 hello 计数、跨前缀拒绝由 SDK 层保证(#9/#60 用例),
* 这里验证 HTTP 面验签/计数/生命周期骨架。
*/

/** 内存 module_kv(与 #9 存储模型一致:module_id 列隔离)。 */
function makeDb(): D1Database & { _rows: Map<string, Map<string, string>> } {
const rows = new Map<string, Map<string, string>>();
const prepare = (sql: string) => {
const chain = {
_args: [] as unknown[],
bind(...args: unknown[]) {
chain._args = args;
return chain;
},
async first<T>(): Promise<T | null> {
const [moduleId, key] = chain._args as [string, string];
if (sql.includes('SELECT value FROM')) {
const value = rows.get(moduleId)?.get(key);
return (value !== undefined ? { value } : null) as T | null;
}
return null;
},
async all<T>() {
const [moduleId] = chain._args as [string];
const keys = rows.get(moduleId) ?? new Map();
const results = [...keys.entries()]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([key]) => ({ key }));
return { results } as { results: T[] };
},
async run() {
const [moduleId, key, value] = chain._args as [string, string, string];
if (sql.startsWith('INSERT INTO')) {
if (!rows.has(moduleId)) rows.set(moduleId, new Map());
rows.get(moduleId)!.set(key, value);
} else if (sql.startsWith('DELETE FROM')) {
rows.get(moduleId)?.delete(key);
}
return { success: true };
},
};
return chain;
};
return { prepare, _rows: rows } as unknown as D1Database & { _rows: Map<string, Map<string, string>> };
}

let privateKey: CryptoKey;

/** 造 aud=hello 的合法模块 token。 */
Expand All @@ -65,22 +24,34 @@ async function makeToken(overrides: Record<string, unknown> = {}): Promise<strin
}

/** 假 Core:/.well-known/jwks.json 返回测试公钥;token 校验走真实 jose。 */
async function envFor(): Promise<{ MODULES_DB: D1Database & { _rows: Map<string, Map<string, string>> }; CORE_JWKS_URL: string }> {
async function envFor(): Promise<{
MODULES_DB: D1Database;
CORE_JWKS_URL: string;
db: ModuleTestDb;
}> {
const pair = await generateKeyPair('ES256', { extractable: true });
privateKey = pair.privateKey;
const publicJwk = await exportJWK(pair.publicKey);
const jwksBody = JSON.stringify({ keys: [{ ...publicJwk, use: 'sig', alg: 'ES256' }] });
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
if (String(input).includes('/.well-known/jwks.json')) {
return new Response(jwksBody, { status: 200, headers: { 'content-type': 'application/json' } });
globalThis.fetch = (
async (input: RequestInfo | URL) => {
if (String(input).includes('/.well-known/jwks.json')) {
return new Response(jwksBody, { status: 200, headers: { 'content-type': 'application/json' } });
}
throw new Error(`unexpected fetch ${String(input)}`);
}
throw new Error(`unexpected fetch ${String(input)}`);
}) as typeof fetch;
) as typeof fetch;
const db = createModuleDb();
afterEach(() => {
globalThis.fetch = originalFetch;
db.close();
});
return { MODULES_DB: makeDb(), CORE_JWKS_URL: 'https://core.example/.well-known/jwks.json' };
return {
MODULES_DB: db.d1 as unknown as D1Database,
CORE_JWKS_URL: 'https://core.example/.well-known/jwks.json',
db,
};
}

describe('module-hello(#13 垂直切片载体)', () => {
Expand Down Expand Up @@ -120,12 +91,18 @@ describe('module-hello(#13 垂直切片载体)', () => {
expect(await again.json()).toEqual({ count: 1 });
});

it('计数写入落在 hello 子域(module_id=hello,SDK 收口)', async () => {
it('计数写入落在 hello 子域(真库直查 module_kv 行)', async () => {
const env = await envFor();
const token = await makeToken();
await app.request('https://m.example/api/count', { method: 'POST', headers: { authorization: `Bearer ${token}` } }, env);
// SDK 键模型:module_kv(module_id='hello', key='counter')
expect(env.MODULES_DB._rows.get('hello')?.get('counter')).toBe('1');
// SDK 键模型:module_kv(module_id='hello', key='counter')——直查真库行(不经适配器)
expect(
env.db.first<{ value: string }>(
'SELECT value FROM module_kv WHERE module_id = ? AND key = ?',
'hello',
'counter',
)?.value,
).toBe('1');
});

it('身份行数据源:claims 姓名/邮箱进入 token(验收 2 的数据面)', async () => {
Expand Down
9 changes: 9 additions & 0 deletions modules/hello/test/node-builtins.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-only

/**
* modules/hello 复用 package/module-sdk 的测试工厂(真 SQLite module_kv)。
* 本文件把 module-sdk/test/node-builtins.d.ts 拉进 hello 的编译程序——
* 两个工厂对 node:sqlite/fs/url 的最小声明与 @types/node 声明合并后,
* 跨包 import 的文件(module-sdk/test/test-factory.ts)即可通过 tsc --noEmit。
*/
/// <reference path="../../../packages/module-sdk/test/node-builtins.d.ts" />
29 changes: 29 additions & 0 deletions packages/module-sdk/test/node-builtins.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: AGPL-3.0-only

/**
* node 内置模块的最小类型声明(packages/module-sdk 的 tsconfig 不引 @types/node,
* 这里只声明测试工厂用到的三个内置模块,零新依赖)。
*/
declare module 'node:sqlite' {
/** 预处理语句:参数位置绑定与 all/get/run 三个执行面。 */
export interface StatementSync {
all(...params: unknown[]): Record<string, unknown>[];
get(...params: unknown[]): Record<string, unknown> | undefined;
run(...params: unknown[]): { changes: number | bigint; lastInsertRowid: number | bigint };
}
export class DatabaseSync {
constructor(path: string);
exec(sql: string): void;
prepare(sql: string): StatementSync;
close(): void;
}
}

declare module 'node:fs' {
export function readdirSync(path: string): string[];
export function readFileSync(path: string, encoding: 'utf8'): string;
}

declare module 'node:url' {
export function fileURLToPath(url: string | URL): string;
}
Loading
Loading