/** * createFakeUserRepo —— 全内存 UserRepo 实现,测试用(证明所有接缝可替换)。 * * 与真实 repo.js 行为对齐:会话族 / refresh hash / 防爆破计数 / 验证码 hash 全在内存 Map, * 无网络无 DB 即可跑通 register / login / oauth / refresh / code 全流程。 */ /** * @param {Object} [seed] * @param {Array} [seed.users] * @returns {import('./index.js').UserRepo} */ export function createFakeUserRepo(seed = {}) { /** @type {Map} */ const usersById = new Map() /** @type {Map} */ const sessions = new Map() /** @type {Array<{ key: string, ip?: string, at: Date }>} */ const attempts = [] /** @type {Map} */ const codes = new Map() for (const u of seed.users ?? []) { usersById.set(u.id, { ...u, oauth_providers: u.oauth_providers ?? [] }) } /** @param {string} e */ const norm = (e) => String(e ?? '').trim().toLowerCase() /** @param {string} email @param {string} purpose */ const codeKey = (email, purpose) => `${norm(email)}|${purpose}` let idSeq = usersById.size /** @param {string} email */ function findUserByEmail(email) { const e = norm(email) for (const u of usersById.values()) if (norm(u.email) === e) return u return null } return { async findByEmail(email) { const u = findUserByEmail(email) return u ? clone(u) : null }, async findById(id) { const u = usersById.get(id) return u ? clone(u) : null }, async create({ email, password_hash, oauth_providers, locale }) { idSeq += 1 const id = `u${idSeq}` const user = { id, email: norm(email), password_hash, locale, oauth_providers: oauth_providers ?? [], } usersById.set(id, user) return clone(user) }, async linkOauth({ user_id, provider, provider_user_id }) { const u = usersById.get(user_id) if (!u) throw new Error('user not found') u.oauth_providers = u.oauth_providers ?? [] const dup = u.oauth_providers.some( (p) => p.provider === provider && p.provider_user_id === provider_user_id, ) if (!dup) u.oauth_providers.push({ provider, provider_user_id }) return clone(u) }, async updatePasswordHash({ user_id, password_hash }) { const u = usersById.get(user_id) if (!u) throw new Error('user not found') u.password_hash = password_hash }, async saveSession({ user_id, sid, refresh_hash, family, expires_at }) { sessions.set(sid, { sid, user_id, refresh_hash, family, expires_at, used: false, revoked: false }) }, async findSession({ sid }) { const s = sessions.get(sid) return s ? { ...s } : null }, async markRefreshUsed({ sid }) { const s = sessions.get(sid) if (s) s.used = true }, async revokeFamily({ family }) { for (const s of sessions.values()) if (s.family === family) s.revoked = true }, async revokeUserSessions({ user_id }) { for (const s of sessions.values()) if (s.user_id === user_id) s.revoked = true }, async recordAttempt({ key, ip, at }) { attempts.push({ key, ip, at }) }, async countRecentAttempts({ key, windowMs }) { const since = Date.now() - windowMs return attempts.filter((a) => a.key === key && a.at.getTime() >= since).length }, async saveCode({ email, purpose, code_hash, expires_at }) { codes.set(codeKey(email, purpose), { email: norm(email), purpose, code_hash, expires_at, attempts: 0, }) }, async consumeCode({ email, purpose }) { const rec = codes.get(codeKey(email, purpose)) if (!rec) return null const snapshot = { ...rec } rec.attempts += 1 return snapshot }, } } /** @param {any} o */ function clone(o) { // structuredClone 保留 Date(时间字段与真实 repo 一致返回 Date),JSON round-trip 会把它变字符串。 return structuredClone(o) }