refactor(server): 拆分 ShrimpServer → buildShrimpRouter + tokenAuth + accessor
ShrimpServer 之前一锅端 Express routes / JWT / Mongoose / 用户字段访问。
现在拆成独立部件,消费方可以单独换掉任何一块:
- src/server/types.ts: ITokenAuth, IUserAccessor, IShrimpRouterDeps
- src/server/jwtAuth.ts: createJwtTokenAuth(secret | options) 默认 JWT 适配器
- src/server/buildShrimpRouter.ts: 纯函数,不直接 import jsonwebtoken/mongoose
- src/server/ShrimpServer.ts: 装配体,protected create* factory 保留,
子类化扩展不破坏(hand-server-2 的 AdminApi extends ShrimpServer 仍能用)
- src/server/index.ts: 'shrimp-server/server' entry,额外暴露低阶 API
test/buildShrimpRouter.test.ts: 用 MemoryShrimpCrud + 假 ITokenAuth 拼出
独立 Router,跑通 /config /login /me 全流程 — 保证 buildShrimpRouter 不
偷偷耦合回 ShrimpServer。
v0.3.0
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* buildShrimpRouter 独立装配冒烟测试
|
||||
*
|
||||
* 验证 buildShrimpRouter 不依赖 ShrimpServer 类,消费方完全可以:
|
||||
* - 用 MemoryShrimpCrud(而非 MongoShrimpCrud)
|
||||
* - 自己造 ITokenAuth(而非默认 JWT)
|
||||
* - 自己实现 IUserAccessor
|
||||
* 然后拼出可工作的 Express Router。
|
||||
*
|
||||
* 这是"内部解耦"的核心契约 — 如果哪天 buildShrimpRouter 偷偷耦合回
|
||||
* ShrimpServer / mongoose / jsonwebtoken,本测试会编译失败或运行失败。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest"
|
||||
import express from "express"
|
||||
import {
|
||||
Shrimp,
|
||||
MemoryShrimpCrud,
|
||||
UserModule,
|
||||
RoleModule,
|
||||
OperationLogModule,
|
||||
plainHasher,
|
||||
} from "../src/core"
|
||||
import { buildShrimpRouter } from "../src/server/buildShrimpRouter"
|
||||
import type { ITokenAuth, IUserAccessor } from "../src/server/types"
|
||||
import type { WithId, IUser, IRole } from "../src/core"
|
||||
|
||||
function makeFakeTokenAuth(): ITokenAuth {
|
||||
const store = new Map<string, Record<string, any>>()
|
||||
let seq = 0
|
||||
return {
|
||||
sign(payload) {
|
||||
const tok = `tok-${++seq}`
|
||||
store.set(tok, payload)
|
||||
return tok
|
||||
},
|
||||
verify(token) {
|
||||
const payload = store.get(token)
|
||||
if (!payload) throw new Error("invalid token")
|
||||
return payload
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const userAccessor: IUserAccessor = {
|
||||
getJwtPayload: (user) => ({ _id: user._id }),
|
||||
isUserActive: (user) => user.active === true,
|
||||
getUserRoleIds: (user) =>
|
||||
Array.isArray(user.roleId) ? user.roleId.map(String) : user.roleId ? [String(user.roleId)] : [],
|
||||
isRoleSuperAdmin: (role) => !!role?.isSuperAdmin,
|
||||
getRolePowers: (role) => role?.powers || [],
|
||||
getUserDisplayName: (user) => user.nickname || "",
|
||||
}
|
||||
|
||||
async function makeApp() {
|
||||
Shrimp.passwordHasher = plainHasher
|
||||
|
||||
const shrimp = new Shrimp()
|
||||
const userCrud = new MemoryShrimpCrud<WithId<IUser>>()
|
||||
const roleCrud = new MemoryShrimpCrud<WithId<IRole>>()
|
||||
const logCrud = new MemoryShrimpCrud<any>()
|
||||
|
||||
const userModule = new UserModule(shrimp, userCrud)
|
||||
const roleModule = new RoleModule(shrimp, roleCrud)
|
||||
const operationLogModule = new OperationLogModule(userModule, logCrud)
|
||||
|
||||
const adminRole = await roleCrud.add({
|
||||
name: "admin",
|
||||
description: "",
|
||||
isSuperAdmin: true,
|
||||
powers: [],
|
||||
} as any)
|
||||
await userModule.add({
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
nickname: "Alice",
|
||||
roleId: [adminRole._id],
|
||||
active: true,
|
||||
} as any)
|
||||
|
||||
const tokenAuth = makeFakeTokenAuth()
|
||||
|
||||
const router = buildShrimpRouter({
|
||||
shrimp,
|
||||
userModule,
|
||||
roleModule,
|
||||
operationLogModule,
|
||||
tokenAuth,
|
||||
config: { title: "smoke" },
|
||||
globalOptions: {},
|
||||
forcedOptionAccess: new Map(),
|
||||
userAccessor,
|
||||
})
|
||||
|
||||
const app = express()
|
||||
app.use(express.json())
|
||||
app.use(router)
|
||||
return app
|
||||
}
|
||||
|
||||
async function jsonFetch(app: express.Express, method: string, path: string, body?: any, headers: Record<string, string> = {}) {
|
||||
return new Promise<{ status: number; body: any }>((resolve, reject) => {
|
||||
const server = app.listen(0, () => {
|
||||
const port = (server.address() as any).port
|
||||
const data = body == null ? undefined : JSON.stringify(body)
|
||||
const req = require("http").request(
|
||||
{
|
||||
method,
|
||||
host: "127.0.0.1",
|
||||
port,
|
||||
path,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(data ? { "content-length": Buffer.byteLength(data) } : {}),
|
||||
...headers,
|
||||
},
|
||||
},
|
||||
(res: any) => {
|
||||
let buf = ""
|
||||
res.on("data", (c: any) => (buf += c))
|
||||
res.on("end", () => {
|
||||
server.close()
|
||||
try {
|
||||
resolve({ status: res.statusCode, body: JSON.parse(buf) })
|
||||
} catch (e) {
|
||||
reject(new Error("non-JSON body: " + buf))
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
req.on("error", reject)
|
||||
if (data) req.write(data)
|
||||
req.end()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe("buildShrimpRouter 独立装配", () => {
|
||||
it("可以用纯内存 CRUD + 假 token 鉴权拼出可工作的 Router", async () => {
|
||||
const app = await makeApp()
|
||||
|
||||
// /config 不需要鉴权
|
||||
const cfg = await jsonFetch(app, "GET", "/config")
|
||||
expect(cfg.body.data).toEqual({ title: "smoke" })
|
||||
|
||||
// /login 用注入的 IUserAccessor + fake tokenAuth
|
||||
const login = await jsonFetch(app, "POST", "/login", { username: "alice", password: "secret" })
|
||||
expect(login.body.code).toBe(0)
|
||||
const token = login.body.data.token
|
||||
expect(typeof token).toBe("string")
|
||||
|
||||
// 无 token 拒绝
|
||||
const noTok = await jsonFetch(app, "GET", "/me")
|
||||
expect(noTok.body.code).not.toBe(0)
|
||||
|
||||
// 有 token 通过
|
||||
const me = await jsonFetch(app, "GET", "/me", undefined, { authorization: `Bearer ${token}` })
|
||||
expect(me.body.code).toBe(0)
|
||||
expect(me.body.data.nickname).toBe("Alice")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user