fake-repo.js 4.07 KB
/**
 * createFakeUserRepo —— 全内存 UserRepo 实现,测试用(证明所有接缝可替换)。
 *
 * 与真实 repo.js 行为对齐:会话族 / refresh hash / 防爆破计数 / 验证码 hash 全在内存 Map,
 * 无网络无 DB 即可跑通 register / login / oauth / refresh / code 全流程。
 */

/**
 * @param {Object} [seed]
 * @param {Array<import('./index.js').User>} [seed.users]
 * @returns {import('./index.js').UserRepo}
 */
export function createFakeUserRepo(seed = {}) {
  /** @type {Map<string, import('./index.js').User>} */
  const usersById = new Map()
  /** @type {Map<string, import('./index.js').Session>} */
  const sessions = new Map()
  /** @type {Array<{ key: string, ip?: string, at: Date }>} */
  const attempts = []
  /** @type {Map<string, import('./index.js').CodeRecord>} */
  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)
}