import * as fs from 'fs' import * as path from 'path' import * as crypto from 'crypto' import logger from '../utils/log' export const MockMode = { RECORD: 'record', REPLAY: 'replay' } as const export type MockMode = (typeof MockMode)[keyof typeof MockMode] interface FixtureData { request: { method: string url: string body?: any } response: { status: number headers: Record body: any } recorded_at: string } const SENSITIVE_HEADERS = [ 'authorization', 'cookie', 'set-cookie', 'x-csrftoken', 'x-forwarded-for' ] class FixtureManager { private fixturesDir: string private dirEnsured = false constructor() { this.fixturesDir = path.join(process.cwd(), '.pz-fixtures') } private ensureDir(): void { if (this.dirEnsured) return fs.mkdirSync(this.fixturesDir, { recursive: true }) this.dirEnsured = true } generateKey(method: string, url: string, body?: any): string { const normalized = method?.toUpperCase() ?? 'GET' const parts = [normalized, url] if (body && normalized !== 'GET') { const bodyStr = typeof body === 'string' ? body : JSON.stringify(body) parts.push(bodyStr) } return crypto.createHash('md5').update(parts.join(':')).digest('hex') } extractHeaders(headers: Headers): Record { const result: Record = {} headers.forEach((value, key) => { result[key] = value }) return result } private stripSensitiveHeaders( headers: Record ): Record { const cleaned: Record = {} for (const [key, value] of Object.entries(headers)) { if (!SENSITIVE_HEADERS.includes(key.toLowerCase())) { cleaned[key] = value } } return cleaned } async write( method: string, url: string, body: any, response: { status: number; headers: Record; body: any } ): Promise { try { this.ensureDir() const normalized = method?.toUpperCase() ?? 'GET' const key = this.generateKey(normalized, url, body) const fixture: FixtureData = { request: { method: normalized, url }, response: { status: response.status, headers: this.stripSensitiveHeaders(response.headers), body: response.body }, recorded_at: new Date().toISOString() } if (body && normalized !== 'GET') { fixture.request.body = body } const filePath = path.join(this.fixturesDir, `${key}.json`) await fs.promises.writeFile(filePath, JSON.stringify(fixture, null, 2)) logger.debug(`[pz-mock] Recorded fixture: ${normalized} ${url} → ${filePath}`) } catch (error) { logger.error(`[pz-mock] Failed to write fixture`, { url, error }) } } async read( method: string, url: string, body?: any ): Promise<{ found: boolean; fixture?: FixtureData }> { try { const key = this.generateKey(method, url, body) const filePath = path.join(this.fixturesDir, `${key}.json`) const raw = await fs.promises.readFile(filePath, 'utf-8') const fixture = JSON.parse(raw) as FixtureData logger.debug(`[pz-mock] Replaying fixture: ${method} ${url} → ${key}`) return { found: true, fixture } } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { logger.warn(`[pz-mock] No fixture found: ${method} ${url}`) return { found: false } } logger.error(`[pz-mock] Failed to read fixture`, { url, error }) return { found: false } } } } export const fixtureManager = new FixtureManager()