Files

275 lines
7.2 KiB
JavaScript

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: {},
};