first commit
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildApp } from '../src/app.js';
|
||||
import { loadConfig } from '../src/config/env.js';
|
||||
import { SESSION_COOKIE_NAME } from '../src/plugins/auth.js';
|
||||
import {
|
||||
createTestConfig,
|
||||
FakeAllowedEmailRepository,
|
||||
FakeRecipeRepository,
|
||||
fakeUserRepository,
|
||||
recipeDraft,
|
||||
source,
|
||||
} from './helpers/fakes.js';
|
||||
|
||||
function createDependencies(recipeRepository) {
|
||||
return {
|
||||
allowedEmailRepository: new FakeAllowedEmailRepository(),
|
||||
recipeRepository,
|
||||
userRepository: fakeUserRepository,
|
||||
sourceExtractor: { extract: async () => source },
|
||||
recipeParser: { parse: async () => recipeDraft },
|
||||
imageStorage: {
|
||||
saveFromUrl: async (id) => `recipes/${id}/cover.webp`,
|
||||
removeRecipe: async () => {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function authCookie(app, sub = 'google-user-1') {
|
||||
const token = app.jwt.sign({
|
||||
sub,
|
||||
email: `${sub}@example.com`,
|
||||
name: sub,
|
||||
picture: null,
|
||||
});
|
||||
return `${SESSION_COOKIE_NAME}=${token}`;
|
||||
}
|
||||
|
||||
test('로그인 세션 만료시간은 기본 24시간이며 환경변수로 변경할 수 있다', () => {
|
||||
assert.equal(loadConfig({}, { platform: 'win32' }).authSessionTtlHours, 24);
|
||||
assert.equal(
|
||||
loadConfig({ AUTH_SESSION_TTL_HOURS: '8' }, { platform: 'win32' }).authSessionTtlHours,
|
||||
8,
|
||||
);
|
||||
});
|
||||
|
||||
test('관리자는 이메일만으로 Google 로그인 허용 계정을 관리한다', async (t) => {
|
||||
const dependencies = createDependencies(new FakeRecipeRepository());
|
||||
const app = await buildApp({ config: createTestConfig(), dependencies });
|
||||
t.after(() => app.close());
|
||||
const adminCookie = authCookie(app);
|
||||
|
||||
const sessionResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/me',
|
||||
headers: { cookie: adminCookie },
|
||||
});
|
||||
assert.equal(sessionResponse.json().user.canManageAccess, true);
|
||||
|
||||
const memberSessionResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/me',
|
||||
headers: { cookie: authCookie(app, 'google-user-2') },
|
||||
});
|
||||
assert.equal(memberSessionResponse.json().user.canManageAccess, false);
|
||||
|
||||
const pageResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
headers: { cookie: adminCookie },
|
||||
});
|
||||
assert.match(pageResponse.body, /id="access-menu-button"/);
|
||||
assert.match(pageResponse.body, /id="access-drawer"/);
|
||||
assert.match(pageResponse.body, /id="drawer-logout"/);
|
||||
assert.match(pageResponse.body, /id="access-overlay"/);
|
||||
assert.match(pageResponse.body, /id="scroll-to-top"/);
|
||||
|
||||
const memberPageResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
headers: { cookie: authCookie(app, 'google-user-2') },
|
||||
});
|
||||
assert.match(memberPageResponse.body, /aria-label="메뉴 열기"/);
|
||||
|
||||
const addResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/allowed-emails',
|
||||
headers: { cookie: adminCookie },
|
||||
payload: { email: ' New.User@Example.com ' },
|
||||
});
|
||||
assert.equal(addResponse.statusCode, 201);
|
||||
assert.equal(addResponse.json().email, 'new.user@example.com');
|
||||
|
||||
const listResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/allowed-emails',
|
||||
headers: { cookie: adminCookie },
|
||||
});
|
||||
assert.ok(listResponse.json().emails.includes('new.user@example.com'));
|
||||
|
||||
const invitedSessionResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/recipes',
|
||||
headers: { cookie: authCookie(app, 'new.user') },
|
||||
});
|
||||
assert.equal(invitedSessionResponse.statusCode, 200);
|
||||
|
||||
const memberResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/allowed-emails',
|
||||
headers: { cookie: authCookie(app, 'google-user-2') },
|
||||
payload: { email: 'other@example.com' },
|
||||
});
|
||||
assert.equal(memberResponse.statusCode, 403);
|
||||
|
||||
const selfDeleteResponse = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/allowed-emails/google-user-1%40example.com',
|
||||
headers: { cookie: adminCookie },
|
||||
});
|
||||
assert.equal(selfDeleteResponse.statusCode, 400);
|
||||
|
||||
const deleteResponse = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/allowed-emails/new.user%40example.com',
|
||||
headers: { cookie: adminCookie },
|
||||
});
|
||||
assert.equal(deleteResponse.statusCode, 204);
|
||||
|
||||
const revokedSessionResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/recipes',
|
||||
headers: { cookie: authCookie(app, 'new.user') },
|
||||
});
|
||||
assert.equal(revokedSessionResponse.statusCode, 401);
|
||||
});
|
||||
|
||||
test('인증 cookie가 없으면 API를 거부하고 페이지는 로그인으로 보낸다', async (t) => {
|
||||
const app = await buildApp({
|
||||
config: createTestConfig(),
|
||||
dependencies: createDependencies(new FakeRecipeRepository()),
|
||||
});
|
||||
t.after(() => app.close());
|
||||
|
||||
const apiResponse = await app.inject({ method: 'GET', url: '/api/recipes' });
|
||||
assert.equal(apiResponse.statusCode, 401);
|
||||
|
||||
const pageResponse = await app.inject({ method: 'GET', url: '/' });
|
||||
assert.equal(pageResponse.statusCode, 302);
|
||||
assert.equal(pageResponse.headers.location, '/login');
|
||||
|
||||
const loginResponse = await app.inject({ method: 'GET', url: '/login' });
|
||||
assert.equal(loginResponse.statusCode, 200);
|
||||
assert.match(loginResponse.body, /Our Recipe Atlas/);
|
||||
|
||||
const cssResponse = await app.inject({ method: 'GET', url: '/css/app.css' });
|
||||
assert.equal(cssResponse.statusCode, 200);
|
||||
});
|
||||
|
||||
test('설정된 절대 만료시간보다 오래된 로그인 세션을 거부한다', async (t) => {
|
||||
const app = await buildApp({
|
||||
config: createTestConfig({ authSessionTtlHours: 1 }),
|
||||
dependencies: createDependencies(new FakeRecipeRepository()),
|
||||
});
|
||||
t.after(() => app.close());
|
||||
|
||||
const token = app.jwt.sign({
|
||||
sub: 'google-user-1',
|
||||
email: 'google-user-1@example.com',
|
||||
name: 'google-user-1',
|
||||
picture: null,
|
||||
iat: Math.floor(Date.now() / 1000) - (60 * 60) - 1,
|
||||
}, { expiresIn: '7d' });
|
||||
const cookie = `${SESSION_COOKIE_NAME}=${token}`;
|
||||
|
||||
const apiResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/recipes',
|
||||
headers: { cookie },
|
||||
});
|
||||
assert.equal(apiResponse.statusCode, 401);
|
||||
|
||||
const pageResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
headers: { cookie },
|
||||
});
|
||||
assert.equal(pageResponse.statusCode, 302);
|
||||
assert.equal(pageResponse.headers.location, '/login');
|
||||
});
|
||||
|
||||
test('Google 로그인 시작 경로는 PKCE S256 authorization 요청을 만든다', async (t) => {
|
||||
const app = await buildApp({
|
||||
config: createTestConfig({
|
||||
google: {
|
||||
clientId: 'google-client-id',
|
||||
clientSecret: 'google-client-secret',
|
||||
callbackUrl: 'http://localhost:3000/auth/google/callback',
|
||||
allowedEmails: new Set(['allowed@example.com']),
|
||||
},
|
||||
}),
|
||||
dependencies: createDependencies(new FakeRecipeRepository()),
|
||||
});
|
||||
t.after(() => app.close());
|
||||
|
||||
const response = await app.inject({ method: 'GET', url: '/auth/google' });
|
||||
assert.equal(response.statusCode, 302);
|
||||
const location = new URL(response.headers.location);
|
||||
assert.equal(location.hostname, 'accounts.google.com');
|
||||
assert.equal(location.searchParams.get('code_challenge_method'), 'S256');
|
||||
assert.equal(location.searchParams.get('scope'), 'openid email profile');
|
||||
});
|
||||
|
||||
test('import preview와 소유자별 Recipe CRUD가 이어진다', async (t) => {
|
||||
const recipeRepository = new FakeRecipeRepository();
|
||||
const app = await buildApp({
|
||||
config: createTestConfig(),
|
||||
dependencies: createDependencies(recipeRepository),
|
||||
});
|
||||
t.after(() => app.close());
|
||||
const cookie = authCookie(app);
|
||||
|
||||
const previewResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/import/preview',
|
||||
headers: { cookie },
|
||||
payload: { url: source.sourceUrl },
|
||||
});
|
||||
assert.equal(previewResponse.statusCode, 200);
|
||||
assert.equal(previewResponse.json().recipe.title, recipeDraft.title);
|
||||
|
||||
const createResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/recipes',
|
||||
headers: { cookie },
|
||||
payload: {
|
||||
recipe: recipeDraft,
|
||||
source,
|
||||
imagePreviewUrl: source.thumbnailUrl,
|
||||
},
|
||||
});
|
||||
assert.equal(createResponse.statusCode, 201);
|
||||
const created = createResponse.json().recipe;
|
||||
assert.equal(created.source.url, source.sourceUrl);
|
||||
assert.match(created.imagePath, /^recipes\/.+\/cover\.webp$/);
|
||||
|
||||
const listResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/recipes',
|
||||
headers: { cookie },
|
||||
});
|
||||
assert.equal(listResponse.json().recipes.length, 1);
|
||||
|
||||
const otherOwnerResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/recipes/${created._id}`,
|
||||
headers: { cookie: authCookie(app, 'google-user-2') },
|
||||
});
|
||||
assert.equal(otherOwnerResponse.statusCode, 404);
|
||||
|
||||
const updateResponse = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/recipes/${created._id}`,
|
||||
headers: { cookie },
|
||||
payload: { title: '수정한 김치찌개' },
|
||||
});
|
||||
assert.equal(updateResponse.json().recipe.title, '수정한 김치찌개');
|
||||
|
||||
const duplicateResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/recipes',
|
||||
headers: { cookie },
|
||||
payload: { recipe: recipeDraft, source },
|
||||
});
|
||||
assert.equal(duplicateResponse.statusCode, 409);
|
||||
|
||||
const deleteResponse = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/recipes/${created._id}`,
|
||||
headers: { cookie },
|
||||
});
|
||||
assert.equal(deleteResponse.statusCode, 204);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
InstagramExtractor,
|
||||
normalizeInstagramSessionCookie,
|
||||
} from '../src/services/extractors/instagram.extractor.js';
|
||||
import { readTranscriptSegments } from '../src/services/extractors/youtube.extractor.js';
|
||||
|
||||
test('Instagram session ID를 Cookie 헤더 형식으로 정규화한다', () => {
|
||||
assert.equal(
|
||||
normalizeInstagramSessionCookie('123456%3Aexample'),
|
||||
'sessionid=123456%3Aexample;',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeInstagramSessionCookie('sessionid=123456%3Aexample;'),
|
||||
'sessionid=123456%3Aexample;',
|
||||
);
|
||||
});
|
||||
|
||||
test('Instagram 릴스의 커버 이미지를 썸네일로 사용한다', async () => {
|
||||
let requestedMediaId;
|
||||
const extractor = new InstagramExtractor({
|
||||
sessionCookie: '123456%3Aexample',
|
||||
clientFactory: () => ({
|
||||
fetchPost: async () => ({
|
||||
caption: '테스트 레시피',
|
||||
media_id: 'media-1',
|
||||
shortcode: 'Db137BuzUJe',
|
||||
postType: 'reel',
|
||||
links: [{ type: 'video', url: 'https://video.example.com/reel.mp4' }],
|
||||
}),
|
||||
fetchPostByMediaId: async (mediaId) => {
|
||||
requestedMediaId = mediaId;
|
||||
return {
|
||||
items: [{
|
||||
image_versions2: {
|
||||
candidates: [{ url: 'https://images.example.com/reel-cover.jpg' }],
|
||||
},
|
||||
}],
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const source = await extractor.extract('https://www.instagram.com/reel/Db137BuzUJe/');
|
||||
|
||||
assert.equal(requestedMediaId, 'media-1');
|
||||
assert.equal(source.thumbnailUrl, 'https://images.example.com/reel-cover.jpg');
|
||||
});
|
||||
|
||||
test('youtubei.js transcript 구조에서 timestamp를 보존한다', () => {
|
||||
const result = readTranscriptSegments({
|
||||
transcript: {
|
||||
content: {
|
||||
body: {
|
||||
initial_segments: [
|
||||
{ start_ms: '12400', end_ms: '18000', snippet: { toString: () => '김치를 볶는다' } },
|
||||
{ start_ms: '18000', end_ms: '24000', snippet: { toString: () => '물을 넣는다' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, [
|
||||
{ startSec: 12.4, endSec: 18, text: '김치를 볶는다' },
|
||||
{ startSec: 18, endSec: 24, text: '물을 넣는다' },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
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: {},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildRelativeImagePath,
|
||||
isPrivateAddress,
|
||||
resolveImagePath,
|
||||
} from '../src/services/image-storage.service.js';
|
||||
|
||||
test('이미지는 recipe ID 기준 상대 경로로만 저장한다', () => {
|
||||
assert.equal(
|
||||
buildRelativeImagePath('0879bf0f-2b64-49fc-a13f-b11b56031993'),
|
||||
'recipes/0879bf0f-2b64-49fc-a13f-b11b56031993/cover.webp',
|
||||
);
|
||||
assert.throws(() => buildRelativeImagePath('../outside'), /recipe ID/);
|
||||
});
|
||||
|
||||
test('동일한 이미지 상대경로를 Windows와 Linux 파일 경로로 해석한다', () => {
|
||||
const relativePath = 'recipes/recipe-1/cover.webp';
|
||||
|
||||
assert.equal(
|
||||
resolveImagePath('D:\\project\\our_recipe_atlas\\data\\images', relativePath, path.win32),
|
||||
'D:\\project\\our_recipe_atlas\\data\\images\\recipes\\recipe-1\\cover.webp',
|
||||
);
|
||||
assert.equal(
|
||||
resolveImagePath('/mnt/recipe-ssd/our_recipe_atlas/images', relativePath, path.posix),
|
||||
'/mnt/recipe-ssd/our_recipe_atlas/images/recipes/recipe-1/cover.webp',
|
||||
);
|
||||
});
|
||||
|
||||
test('SSRF에 사용될 수 있는 사설 및 loopback 주소를 판별한다', () => {
|
||||
assert.equal(isPrivateAddress('127.0.0.1'), true);
|
||||
assert.equal(isPrivateAddress('192.168.0.250'), true);
|
||||
assert.equal(isPrivateAddress('::1'), true);
|
||||
assert.equal(isPrivateAddress('8.8.8.8'), false);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { loadConfig } from '../src/config/env.js';
|
||||
import { connectFirstAvailableMongo } from '../src/plugins/mongo.js';
|
||||
|
||||
function fakeClient(error = null) {
|
||||
return {
|
||||
closed: false,
|
||||
async connect() {
|
||||
if (error) throw error;
|
||||
},
|
||||
async close() {
|
||||
this.closed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('Windows에서 개발용 MongoDB와 로컬 이미지 프로필을 자동 사용한다', () => {
|
||||
const config = loadConfig({}, { platform: 'win32' });
|
||||
|
||||
assert.equal(config.nodeEnv, 'development');
|
||||
assert.equal(config.mongoUri, 'mongodb://192.168.0.240:27017/our_recipe_atlas');
|
||||
assert.equal(config.mongoFallbackUri, 'mongodb://172.16.0.7:27017/our_recipe_atlas');
|
||||
assert.deepEqual(config.mongoCollections, {
|
||||
allowedEmails: 'allowed_google_emails',
|
||||
recipes: 'recipes_dev',
|
||||
users: 'users_dev',
|
||||
});
|
||||
assert.match(config.imageRoot, /data\\images$/);
|
||||
});
|
||||
|
||||
test('OS 프로필이 환경파일의 개발·운영 선택값보다 우선한다', () => {
|
||||
const linuxConfig = loadConfig(
|
||||
{
|
||||
NODE_ENV: 'development',
|
||||
MONGO_URI: 'mongodb://192.168.0.240:27017/our_recipe_atlas_dev',
|
||||
IMAGE_ROOT: './data/images',
|
||||
AUTH_JWT_SECRET: 'production-secret',
|
||||
},
|
||||
{ platform: 'linux' },
|
||||
);
|
||||
assert.equal(linuxConfig.nodeEnv, 'production');
|
||||
assert.equal(linuxConfig.mongoUri, 'mongodb://192.168.0.240:27017/our_recipe_atlas');
|
||||
assert.deepEqual(linuxConfig.mongoCollections, {
|
||||
allowedEmails: 'allowed_google_emails',
|
||||
recipes: 'recipes',
|
||||
users: 'users',
|
||||
});
|
||||
assert.equal(linuxConfig.imageRoot, '/mnt/recipe-ssd/our_recipe_atlas/images');
|
||||
|
||||
const windowsConfig = loadConfig(
|
||||
{
|
||||
NODE_ENV: 'production',
|
||||
MONGO_URI: 'mongodb://192.168.0.240:27017/our_recipe_atlas',
|
||||
IMAGE_ROOT: '/mnt/recipe-ssd/our_recipe_atlas/images',
|
||||
},
|
||||
{ platform: 'win32' },
|
||||
);
|
||||
assert.equal(windowsConfig.nodeEnv, 'development');
|
||||
assert.equal(windowsConfig.mongoUri, 'mongodb://192.168.0.240:27017/our_recipe_atlas');
|
||||
assert.equal(windowsConfig.mongoCollections.recipes, 'recipes_dev');
|
||||
assert.match(windowsConfig.imageRoot, /data\\images$/);
|
||||
});
|
||||
|
||||
test('Linux 운영용 MongoDB와 절대 이미지 경로 설정을 허용한다', () => {
|
||||
const config = loadConfig(
|
||||
{ AUTH_JWT_SECRET: 'production-secret' },
|
||||
{ platform: 'linux' },
|
||||
);
|
||||
|
||||
assert.equal(config.nodeEnv, 'production');
|
||||
assert.equal(config.mongoUri, 'mongodb://192.168.0.240:27017/our_recipe_atlas');
|
||||
assert.equal(config.mongoFallbackUri, 'mongodb://172.16.0.7:27017/our_recipe_atlas');
|
||||
assert.equal(config.imageRoot, '/mnt/recipe-ssd/our_recipe_atlas/images');
|
||||
});
|
||||
|
||||
test('primary MongoDB 연결에 성공하면 fallback을 시도하지 않는다', async () => {
|
||||
const createdUris = [];
|
||||
const primary = fakeClient();
|
||||
|
||||
const result = await connectFirstAvailableMongo(['primary', 'fallback'], {
|
||||
createClient(uri) {
|
||||
createdUris.push(uri);
|
||||
return primary;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.client, primary);
|
||||
assert.equal(result.connectionIndex, 0);
|
||||
assert.deepEqual(createdUris, ['primary']);
|
||||
});
|
||||
|
||||
test('primary MongoDB 연결 실패 시 정리한 뒤 fallback으로 연결한다', async () => {
|
||||
const primaryError = new Error('primary unavailable');
|
||||
const primary = fakeClient(primaryError);
|
||||
const fallback = fakeClient();
|
||||
const failures = [];
|
||||
|
||||
const result = await connectFirstAvailableMongo(['primary', 'fallback'], {
|
||||
createClient(uri) {
|
||||
return uri === 'primary' ? primary : fallback;
|
||||
},
|
||||
onFailure(index, error) {
|
||||
failures.push({ index, error });
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(primary.closed, true);
|
||||
assert.equal(fallback.closed, false);
|
||||
assert.equal(result.client, fallback);
|
||||
assert.equal(result.connectionIndex, 1);
|
||||
assert.deepEqual(failures, [{ index: 0, error: primaryError }]);
|
||||
});
|
||||
|
||||
test('모든 MongoDB 연결 실패 시 마지막 오류를 반환하고 클라이언트를 정리한다', async () => {
|
||||
const primary = fakeClient(new Error('primary unavailable'));
|
||||
const fallbackError = new Error('fallback unavailable');
|
||||
const fallback = fakeClient(fallbackError);
|
||||
|
||||
await assert.rejects(
|
||||
connectFirstAvailableMongo(['primary', 'fallback'], {
|
||||
createClient(uri) {
|
||||
return uri === 'primary' ? primary : fallback;
|
||||
},
|
||||
}),
|
||||
fallbackError,
|
||||
);
|
||||
|
||||
assert.equal(primary.closed, true);
|
||||
assert.equal(fallback.closed, true);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { searchRecipes } from '../public/js/recipe-search.js';
|
||||
|
||||
function recipe(id, overrides = {}) {
|
||||
return {
|
||||
_id: id,
|
||||
title: '기본 레시피',
|
||||
summary: null,
|
||||
tags: [],
|
||||
ingredientGroups: [{ name: '재료', items: [] }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('재료 정확 일치부터 부분 일치, 제목, 태그, 요약 순으로 검색한다', () => {
|
||||
const recipes = [
|
||||
recipe('summary', { summary: '감자를 맛있게 먹는 방법' }),
|
||||
recipe('tag', { tags: ['감자'] }),
|
||||
recipe('title', { title: '감자 수프' }),
|
||||
recipe('partial', {
|
||||
ingredientGroups: [{ name: '재료', items: [{ name: '알감자' }] }],
|
||||
}),
|
||||
recipe('exact', {
|
||||
ingredientGroups: [{ name: '재료', items: [{ name: '감자' }, { name: '감자전분' }] }],
|
||||
}),
|
||||
];
|
||||
|
||||
const matches = searchRecipes(recipes, ' 감자 ');
|
||||
|
||||
assert.deepEqual(matches.map(({ recipe: item }) => item._id), [
|
||||
'exact',
|
||||
'partial',
|
||||
'title',
|
||||
'tag',
|
||||
'summary',
|
||||
]);
|
||||
assert.deepEqual(matches[0].matchedIngredients, ['감자', '감자전분']);
|
||||
});
|
||||
|
||||
test('검색어가 없으면 기존 레시피 순서를 유지한다', () => {
|
||||
const recipes = [recipe('new'), recipe('old')];
|
||||
assert.deepEqual(
|
||||
searchRecipes(recipes, '').map(({ recipe: item }) => item._id),
|
||||
['new', 'old'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { recipeDraftSchema, recipePatchSchema } from '../src/schemas/recipe.schema.js';
|
||||
import {
|
||||
normalizeModelJson,
|
||||
normalizeRecipePayload,
|
||||
RecipeParserService,
|
||||
} from '../src/services/recipe-parser.service.js';
|
||||
import { recipeDraft } from './helpers/fakes.js';
|
||||
|
||||
test('유효한 AI recipe 결과를 허용한다', () => {
|
||||
assert.deepEqual(recipeDraftSchema.parse(recipeDraft), recipeDraft);
|
||||
});
|
||||
|
||||
test('재료 이름이나 단계가 비어 있으면 거부한다', () => {
|
||||
const invalid = structuredClone(recipeDraft);
|
||||
invalid.ingredientGroups[0].items[0].name = '';
|
||||
assert.equal(recipeDraftSchema.safeParse(invalid).success, false);
|
||||
});
|
||||
|
||||
test('MiniMax reasoning 및 markdown fence에서 JSON만 분리한다', () => {
|
||||
const value = normalizeModelJson('<think>reasoning</think>\n```json\n{"title":"test"}\n```');
|
||||
assert.equal(value, '{"title":"test"}');
|
||||
});
|
||||
|
||||
test('부분 수정은 보내지 않은 nullable/default 필드를 만들지 않는다', () => {
|
||||
assert.deepEqual(recipePatchSchema.parse({ title: '새 제목' }), { title: '새 제목' });
|
||||
});
|
||||
|
||||
test('태그를 표준 태그로 정리하고 최대 3개만 유지한다', () => {
|
||||
const input = structuredClone(recipeDraft);
|
||||
input.tags = [' #한식 ', '한식', ' QUICK MEAL ', '감자요리', '중화요리', '샐러드'];
|
||||
|
||||
assert.deepEqual(recipeDraftSchema.parse(input).tags, ['한식', '간단요리', '중식']);
|
||||
assert.deepEqual(
|
||||
recipePatchSchema.parse({ tags: ['##찌개', ' 샐러드 ', '식단', '고기'] }).tags,
|
||||
['국물', '샐러드', '다이어트'],
|
||||
);
|
||||
});
|
||||
|
||||
test('AI가 비어 있는 재료 그룹 라벨을 반환하면 구조 라벨만 보완한다', () => {
|
||||
const input = structuredClone(recipeDraft);
|
||||
input.ingredientGroups[0].name = null;
|
||||
const normalized = normalizeRecipePayload(input);
|
||||
assert.equal(normalized.ingredientGroups[0].name, '재료');
|
||||
assert.deepEqual(normalized.ingredientGroups[0].items, recipeDraft.ingredientGroups[0].items);
|
||||
});
|
||||
|
||||
test('MiniMax 사고 과정 분리와 충분한 출력 한도를 요청한다', async () => {
|
||||
let request;
|
||||
const client = {
|
||||
chat: {
|
||||
completions: {
|
||||
async create(parameters) {
|
||||
request = parameters;
|
||||
const output = structuredClone(recipeDraft);
|
||||
output.ingredientGroups[0].name = null;
|
||||
return { choices: [{ message: { content: JSON.stringify(output) } }] };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const service = new RecipeParserService({ client, model: 'MiniMax-M2.7' });
|
||||
const result = await service.parse({
|
||||
platform: 'youtube',
|
||||
title: '김치찌개',
|
||||
rawText: '[12.4] 김치를 볶는다.',
|
||||
});
|
||||
|
||||
assert.equal(request.reasoning_split, true);
|
||||
assert.equal(request.max_completion_tokens, 8192);
|
||||
assert.match(request.messages[0].content, /최대 3개/);
|
||||
assert.match(request.messages[0].content, /한식, 중식/);
|
||||
assert.equal(result.ingredientGroups[0].name, '재료');
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
canonicalSourceUrl,
|
||||
detectPlatform,
|
||||
extractInstagramShortcode,
|
||||
extractYouTubeId,
|
||||
} from '../src/utils/url.js';
|
||||
|
||||
test('지원 URL의 플랫폼과 source ID를 판별한다', () => {
|
||||
assert.equal(detectPlatform('https://youtu.be/dQw4w9WgXcQ'), 'youtube');
|
||||
assert.equal(extractYouTubeId('https://www.youtube.com/shorts/dQw4w9WgXcQ'), 'dQw4w9WgXcQ');
|
||||
assert.equal(detectPlatform('https://www.instagram.com/reel/Db137BuzUJe/'), 'instagram');
|
||||
assert.equal(extractInstagramShortcode('https://www.instagram.com/p/Db137BuzUJe/'), 'Db137BuzUJe');
|
||||
});
|
||||
|
||||
test('source URL에서 추적 파라미터를 제거해 정규화한다', () => {
|
||||
assert.equal(
|
||||
canonicalSourceUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ&utm_source=x'),
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
);
|
||||
assert.equal(
|
||||
canonicalSourceUrl('https://www.instagram.com/p/Db137BuzUJe/?igsh=abc'),
|
||||
'https://www.instagram.com/p/Db137BuzUJe/',
|
||||
);
|
||||
});
|
||||
|
||||
test('HTTP 및 지원하지 않는 host를 거부한다', () => {
|
||||
assert.throws(() => detectPlatform('http://youtube.com/watch?v=dQw4w9WgXcQ'), /HTTPS/);
|
||||
assert.equal(detectPlatform('https://example.com/recipe'), 'unsupported');
|
||||
assert.throws(
|
||||
() => extractYouTubeId('https://youtube.com.evil.example/watch?v=dQw4w9WgXcQ'),
|
||||
/지원하지 않는 YouTube URL/,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user