first commit

This commit is contained in:
2026-08-13 18:36:31 +09:00
commit cee36bf12d
48 changed files with 4212 additions and 0 deletions
+283
View File
@@ -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);
});
+69
View File
@@ -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: '물을 넣는다' },
]);
});
+151
View File
@@ -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: {},
};
+36
View File
@@ -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);
});
+131
View File
@@ -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);
});
+47
View File
@@ -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'],
);
});
+75
View File
@@ -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, '재료');
});
+36
View File
@@ -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/,
);
});