export class FakeRecipeRepository { constructor() { this.recipes = new Map(); } async listByOwner(ownerGoogleSub) { return this.listByOwners([ownerGoogleSub]); } async listByOwners(ownerGoogleSubs) { return [...this.recipes.values()] .filter((recipe) => ownerGoogleSubs.includes(recipe.ownerGoogleSub)); } async findById(ownerGoogleSub, recipeId) { const recipe = this.recipes.get(recipeId); return recipe?.ownerGoogleSub === ownerGoogleSub ? recipe : null; } async findByIdForOwners(ownerGoogleSubs, recipeId) { const recipe = this.recipes.get(recipeId); return ownerGoogleSubs.includes(recipe?.ownerGoogleSub) ? recipe : null; } async findBySource(ownerGoogleSub, platform, sourceId) { return [...this.recipes.values()].find((recipe) => ( recipe.ownerGoogleSub === ownerGoogleSub && recipe.source.platform === platform && recipe.source.sourceId === sourceId )) ?? null; } async create(document) { if (await this.findBySource( document.ownerGoogleSub, document.source.platform, document.source.sourceId, )) { const error = new Error('duplicate'); error.code = 11000; throw error; } this.recipes.set(document._id, document); return document; } async update(ownerGoogleSub, recipeId, changes) { const recipe = await this.findById(ownerGoogleSub, recipeId); if (!recipe) return null; const updated = { ...recipe, ...changes, updatedAt: new Date() }; this.recipes.set(recipeId, updated); return updated; } async delete(ownerGoogleSub, recipeId) { const recipe = await this.findById(ownerGoogleSub, recipeId); if (!recipe) return null; this.recipes.delete(recipeId); return recipe; } } export class FakeImportJobRepository { constructor() { this.jobs = new Map(); this.nextId = 1; } async enqueue({ ownerGoogleSub, url, platform, sourceId }) { const existing = [...this.jobs.values()].find((job) => ( job.ownerGoogleSub === ownerGoogleSub && job.platform === platform && job.sourceId === sourceId )); if (existing) return { created: false, job: existing }; const now = new Date(); const job = { _id: `job-${this.nextId}`, ownerGoogleSub, url, platform, sourceId, status: 'pending', attempts: 0, error: null, recipeId: null, createdAt: now, updatedAt: now, startedAt: null, completedAt: null, lockedAt: null, }; this.nextId += 1; this.jobs.set(job._id, job); return { created: true, job }; } async listVisible(ownerGoogleSub) { return [...this.jobs.values()].filter((job) => ( job.ownerGoogleSub === ownerGoogleSub && ['pending', 'processing', 'failed'].includes(job.status) )); } async recoverStale(ownerGoogleSub, staleBefore) { for (const job of this.jobs.values()) { if ( job.ownerGoogleSub === ownerGoogleSub && job.status === 'processing' && job.lockedAt < staleBefore ) { Object.assign(job, { status: 'pending', startedAt: null, lockedAt: null }); } } } async claimNext(ownerGoogleSub) { const job = [...this.jobs.values()].find((candidate) => ( candidate.ownerGoogleSub === ownerGoogleSub && candidate.status === 'pending' )); if (!job) return null; const now = new Date(); Object.assign(job, { status: 'processing', error: null, attempts: job.attempts + 1, startedAt: now, lockedAt: now, updatedAt: now, }); return job; } async markCompleted(jobId, recipeId) { const job = this.jobs.get(jobId); Object.assign(job, { status: 'completed', recipeId, error: null, completedAt: new Date(), lockedAt: null, }); } async markFailed(jobId, error) { Object.assign(this.jobs.get(jobId), { status: 'failed', error, lockedAt: null }); } async retry(ownerGoogleSub, jobId) { const job = this.jobs.get(jobId); if (job?.ownerGoogleSub !== ownerGoogleSub || job.status !== 'failed') return null; Object.assign(job, { status: 'pending', error: null, startedAt: null, lockedAt: null, updatedAt: new Date(), }); return job; } } export class FakeGroupRepository { constructor(groups = []) { this.groups = groups; } async listMemberGoogleSubs(googleSub) { return [...new Set(this.groups .filter((group) => group.memberGoogleSubs.includes(googleSub)) .flatMap((group) => group.memberGoogleSubs))]; } } export class FakeAllowedEmailRepository { constructor(accounts = [ { email: 'google-user-1@example.com', canManageAccess: true }, { email: 'google-user-2@example.com', canManageAccess: false }, ]) { this.accounts = new Map(accounts.map((account) => [account.email, { ...account }])); } async findByEmail(email) { return this.accounts.get(email?.trim().toLowerCase()) ?? null; } async list() { return [...this.accounts.values()] .map(({ email }) => ({ email })) .sort((a, b) => a.email.localeCompare(b.email)); } async add(email, addedBy) { const normalizedEmail = email.trim().toLowerCase(); const account = this.accounts.get(normalizedEmail) ?? { email: normalizedEmail, canManageAccess: false, addedBy, }; this.accounts.set(normalizedEmail, account); return account; } async remove(email) { return this.accounts.delete(email.trim().toLowerCase()); } } export const fakeUserRepository = { async upsertGoogleUser(profile) { return profile; }, }; export function createTestConfig(overrides = {}) { return { nodeEnv: 'test', host: '127.0.0.1', port: 3000, logLevel: 'silent', publicBaseUrl: 'http://localhost:3000', mongoUri: 'mongodb://localhost:27017/our_recipe_atlas_test', mongoFallbackUri: undefined, imageRoot: new URL('../../data/images', import.meta.url).pathname, google: { clientId: undefined, clientSecret: undefined, callbackUrl: 'http://localhost:3000/auth/google/callback', allowedEmails: new Set(['allowed@example.com']), }, authJwtSecret: 'test-secret-that-is-long-enough-for-tests', authSessionTtlHours: 24, minimax: { apiKey: undefined, baseUrl: 'https://api.minimax.io/v1', model: 'MiniMax-M2.7', }, instagramSessionCookie: undefined, ...overrides, }; } export const recipeDraft = { title: '김치찌개', summary: '간단한 김치찌개', servings: '2인분', ingredientGroups: [ { name: '재료', items: [ { name: '김치', amount: '200g' }, { name: '물', amount: '500ml' }, ], }, ], steps: [ { order: 1, text: '김치를 볶는다.', timestampSec: null }, { order: 2, text: '물을 넣고 끓인다.', timestampSec: null }, ], tips: ['신김치를 사용한다.'], tags: ['한식'], }; export const source = { platform: 'instagram', sourceUrl: 'https://www.instagram.com/reel/Db137BuzUJe/', sourceId: 'Db137BuzUJe', title: '김치찌개', author: 'recipe_author', thumbnailUrl: 'https://images.example.com/cover.jpg', rawText: '김치 200g과 물 500ml를 넣고 끓입니다.', metadata: {}, };