export class FakeRecipeRepository { constructor() { this.recipes = new Map(); } async listByOwner(ownerGoogleSub) { return [...this.recipes.values()].filter((recipe) => recipe.ownerGoogleSub === ownerGoogleSub); } async findById(ownerGoogleSub, recipeId) { const recipe = this.recipes.get(recipeId); return recipe?.ownerGoogleSub === 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 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: {}, };