feat: add group recipe sharing and translation

This commit is contained in:
2026-08-14 16:46:01 +09:00
parent 146e78cddf
commit 4491c91455
13 changed files with 170 additions and 19 deletions
+19 -8
View File
@@ -5,11 +5,12 @@ Instagram 게시물과 YouTube 영상의 텍스트를 레시피로 구조화해
## 현재 구현 범위 ## 현재 구현 범위
- Google Authorization Code/OIDC 로그인, PKCE `S256`, 이메일 allowlist - Google Authorization Code/OIDC 로그인, PKCE `S256`, 이메일 allowlist
- HttpOnly 자체 인증 cookie와 소유자별 API 접근 제어 - HttpOnly 자체 인증 cookie와 그룹 멤버 간 recipe 공유
- Instagram caption 추출 및 YouTube 설명·자막·timestamp 추출 - Instagram caption 추출 및 YouTube 설명·자막·timestamp 추출
- MiniMax OpenAI 호환 Chat Completions API를 이용한 레시피 구조화 - MiniMax OpenAI 호환 Chat Completions API를 이용한 레시피 구조화
- 영어 등 외국어 원문의 recipe 필드를 자연스러운 한국어로 번역
- AI 결과 미리보기/편집 후 저장 - AI 결과 미리보기/편집 후 저장
- Recipe CRUD, 소유자·원본별 중복 방지 - Recipe CRUD, 그룹 멤버 조회와 소유자·원본별 중복 방지
- 외부 이미지를 최대 1280px WebP로 변환해 `IMAGE_ROOT`에 저장 - 외부 이미지를 최대 1280px WebP로 변환해 `IMAGE_ROOT`에 저장
- 반응형 Recipe 목록/상세 화면과 YouTube timestamp 링크 - 반응형 Recipe 목록/상세 화면과 YouTube timestamp 링크
- nginx, systemd 운영 예제 - nginx, systemd 운영 예제
@@ -158,12 +159,22 @@ ID와 비밀번호로 서버에서 자동 로그인하지 않습니다. cookie
MongoDB는 `our_recipe_atlas` 하나만 사용하고 collection으로 환경을 분리합니다. MongoDB는 `our_recipe_atlas` 하나만 사용하고 collection으로 환경을 분리합니다.
- 공통: `allowed_google_emails` - 공통: `allowed_google_emails`
- 개발: `users_dev`, `recipes_dev` - 개발: `users_dev`, `groups_dev`, `recipes_dev`
- 운영: `users`, `recipes` - 운영: `users`, `groups`, `recipes`
-`users*.googleSub`: unique index -`users*.googleSub`: unique index
-`groups*.memberGoogleSubs`: 조회 index
-`recipes*`: `ownerGoogleSub + source.platform + source.sourceId` unique index -`recipes*`: `ownerGoogleSub + source.platform + source.sourceId` unique index
- 원본 text와 YouTube transcript를 recipe source에 보존해 재분석에 사용할 수 있습니다. - 원본 text와 YouTube transcript를 recipe source에 보존해 재분석에 사용할 수 있습니다.
그룹 문서는 recipe를 함께 볼 Google 계정의 `sub`를 보관합니다. 그룹이 없는 사용자는 자기 recipe만 볼 수 있고, 같은 그룹의 recipe는 함께 조회하되 수정과 삭제는 작성자만 할 수 있습니다.
```json
{
"name": "우리 가족",
"memberGoogleSubs": ["google-sub-1", "google-sub-2"]
}
```
이미지의 DB 값은 다음과 같은 상대 경로뿐입니다. 이미지의 DB 값은 다음과 같은 상대 경로뿐입니다.
```text ```text
@@ -174,13 +185,13 @@ recipes/<recipe-id>/cover.webp
## API ## API
모든 `/api/**` endpoint는 인증 cookie가 필요하며 로그인한 사용자의 데이터만 반환합니다. 모든 `/api/**` endpoint는 인증 cookie가 필요합니다. Recipe 목록과 상세는 같은 그룹 멤버에게 공유되고, 생성·수정·삭제 권한은 작성자에게 유지됩니다.
| Method | Path | 역할 | | Method | Path | 역할 |
| --- | --- | --- | | --- | --- | --- |
| `POST` | `/api/import/preview` | URL 추출 및 AI recipe 미리보기 | | `POST` | `/api/import/preview` | URL 추출 및 AI recipe 미리보기 |
| `GET` | `/api/recipes` | 내 recipe 목록 | | `GET` | `/api/recipes` | 내 그룹의 recipe 목록 |
| `GET` | `/api/recipes/:id` | 내 recipe 상세 | | `GET` | `/api/recipes/:id` | 내 그룹의 recipe 상세 |
| `POST` | `/api/recipes` | 미리보기 확인 후 저장 | | `POST` | `/api/recipes` | 미리보기 확인 후 저장 |
| `PATCH` | `/api/recipes/:id` | recipe 필드 수정 | | `PATCH` | `/api/recipes/:id` | recipe 필드 수정 |
| `DELETE` | `/api/recipes/:id` | recipe와 로컬 이미지 삭제 | | `DELETE` | `/api/recipes/:id` | recipe와 로컬 이미지 삭제 |
@@ -199,7 +210,7 @@ npm test
npm run lint npm run lint
``` ```
테스트 범위에는 URL/ID 판별, AI schema, timestamp 정규화, allowlist, 인증 middleware, 소유자 격리, CRUD, 중복 source 처리, 이미지 상대경로와 SSRF 차단이 포함됩니다. 테스트 범위에는 URL/ID 판별, AI schema, timestamp 정규화, allowlist, 인증 middleware, 그룹 공유와 소유자 쓰기 권한, CRUD, 중복 source 처리, 이미지 상대경로와 SSRF 차단이 포함됩니다.
실제 계정과 네트워크가 준비된 뒤에는 별도 smoke test로 다음을 확인합니다. 실제 계정과 네트워크가 준비된 뒤에는 별도 smoke test로 다음을 확인합니다.
+8 -4
View File
@@ -8,6 +8,7 @@ const detail = document.querySelector('#recipe-detail');
const toast = document.querySelector('#toast'); const toast = document.querySelector('#toast');
const scrollToTop = document.querySelector('#scroll-to-top'); const scrollToTop = document.querySelector('#scroll-to-top');
let currentRecipe = null; let currentRecipe = null;
let currentUser = null;
let toastTimer; let toastTimer;
bindScrollToTop(scrollToTop); bindScrollToTop(scrollToTop);
@@ -166,6 +167,7 @@ function renderRecipeEditor(recipe) {
function renderRecipe(recipe) { function renderRecipe(recipe) {
document.title = `${recipe.title} · Our Recipe Atlas`; document.title = `${recipe.title} · Our Recipe Atlas`;
const image = recipe.imagePath ? `/media/${recipe.imagePath}` : null; const image = recipe.imagePath ? `/media/${recipe.imagePath}` : null;
const canEdit = recipe.ownerGoogleSub === currentUser.googleSub;
detail.innerHTML = ` detail.innerHTML = `
<a class="back-link" href="/">← Recipe Library</a> <a class="back-link" href="/">← Recipe Library</a>
<article class="recipe-detail"> <article class="recipe-detail">
@@ -177,7 +179,7 @@ function renderRecipe(recipe) {
${recipe.summary ? `<p>${escapeHtml(recipe.summary)}</p>` : ''} ${recipe.summary ? `<p>${escapeHtml(recipe.summary)}</p>` : ''}
${recipe.servings ? `<p class="servings">분량 · ${escapeHtml(recipe.servings)}</p>` : ''} ${recipe.servings ? `<p class="servings">분량 · ${escapeHtml(recipe.servings)}</p>` : ''}
<div class="detail-actions"> <div class="detail-actions">
<button id="edit-recipe" class="button button-primary" type="button">레시피 수정</button> ${canEdit ? '<button id="edit-recipe" class="button button-primary" type="button">레시피 수정</button>' : ''}
<a class="button button-secondary" href="${escapeHtml(recipe.source.url)}" target="_blank" rel="noreferrer">원본 보기 ↗</a> <a class="button button-secondary" href="${escapeHtml(recipe.source.url)}" target="_blank" rel="noreferrer">원본 보기 ↗</a>
</div> </div>
</div> </div>
@@ -205,12 +207,13 @@ function renderRecipe(recipe) {
</div> </div>
<footer class="detail-footer"> <footer class="detail-footer">
<div>${recipe.tags.map((tag) => `<span class="tag">#${escapeHtml(tag)}</span>`).join('')}</div> <div>${recipe.tags.map((tag) => `<span class="tag">#${escapeHtml(tag)}</span>`).join('')}</div>
<button id="delete-recipe" class="button button-danger" type="button">레시피 삭제</button> ${canEdit ? '<button id="delete-recipe" class="button button-danger" type="button">레시피 삭제</button>' : ''}
</footer> </footer>
</article>`; </article>`;
document.querySelector('#edit-recipe').addEventListener('click', () => renderRecipeEditor(recipe)); if (!canEdit) return;
document.querySelector('#edit-recipe').addEventListener('click', () => renderRecipeEditor(recipe));
document.querySelector('#delete-recipe').addEventListener('click', async () => { document.querySelector('#delete-recipe').addEventListener('click', async () => {
if (!window.confirm(`'${recipe.title}' 레시피를 삭제할까요?`)) return; if (!window.confirm(`'${recipe.title}' 레시피를 삭제할까요?`)) return;
try { try {
@@ -223,7 +226,8 @@ function renderRecipe(recipe) {
} }
bindLogout(); bindLogout();
appBar.setUser(await loadSession()); currentUser = await loadSession();
appBar.setUser(currentUser);
try { try {
const recipeId = decodeURIComponent(window.location.pathname.split('/').filter(Boolean).pop()); const recipeId = decodeURIComponent(window.location.pathname.split('/').filter(Boolean).pop());
+2
View File
@@ -55,10 +55,12 @@ export async function buildApp({ config = loadConfig(), dependencies = {} } = {}
if ( if (
dependencies.allowedEmailRepository dependencies.allowedEmailRepository
&& dependencies.groupRepository
&& dependencies.recipeRepository && dependencies.recipeRepository
&& dependencies.userRepository && dependencies.userRepository
) { ) {
app.decorate('allowedEmailRepository', dependencies.allowedEmailRepository); app.decorate('allowedEmailRepository', dependencies.allowedEmailRepository);
app.decorate('groupRepository', dependencies.groupRepository);
app.decorate('recipeRepository', dependencies.recipeRepository); app.decorate('recipeRepository', dependencies.recipeRepository);
app.decorate('userRepository', dependencies.userRepository); app.decorate('userRepository', dependencies.userRepository);
} else { } else {
+1
View File
@@ -37,6 +37,7 @@ export function mongoCollectionNames(nodeEnv) {
const suffix = nodeEnv === 'development' ? '_dev' : nodeEnv === 'test' ? '_test' : ''; const suffix = nodeEnv === 'development' ? '_dev' : nodeEnv === 'test' ? '_test' : '';
return Object.freeze({ return Object.freeze({
allowedEmails: 'allowed_google_emails', allowedEmails: 'allowed_google_emails',
groups: `groups${suffix}`,
recipes: `recipes${suffix}`, recipes: `recipes${suffix}`,
users: `users${suffix}`, users: `users${suffix}`,
}); });
+4
View File
@@ -1,6 +1,7 @@
import { MongoClient } from 'mongodb'; import { MongoClient } from 'mongodb';
import fastifyPlugin from 'fastify-plugin'; import fastifyPlugin from 'fastify-plugin';
import { AllowedEmailRepository } from '../repositories/allowed-email.repository.js'; import { AllowedEmailRepository } from '../repositories/allowed-email.repository.js';
import { GroupRepository } from '../repositories/group.repository.js';
import { RecipeRepository } from '../repositories/recipe.repository.js'; import { RecipeRepository } from '../repositories/recipe.repository.js';
import { UserRepository } from '../repositories/user.repository.js'; import { UserRepository } from '../repositories/user.repository.js';
@@ -49,11 +50,13 @@ async function mongoPlugin(fastify, { config }) {
const db = client.db(); const db = client.db();
const collections = config.mongoCollections; const collections = config.mongoCollections;
const allowedEmailRepository = new AllowedEmailRepository(db, collections.allowedEmails); const allowedEmailRepository = new AllowedEmailRepository(db, collections.allowedEmails);
const groupRepository = new GroupRepository(db, collections.groups);
const recipeRepository = new RecipeRepository(db, collections.recipes); const recipeRepository = new RecipeRepository(db, collections.recipes);
const userRepository = new UserRepository(db, collections.users); const userRepository = new UserRepository(db, collections.users);
await Promise.all([ await Promise.all([
allowedEmailRepository.ensureIndexes(), allowedEmailRepository.ensureIndexes(),
groupRepository.ensureIndexes(),
recipeRepository.ensureIndexes(), recipeRepository.ensureIndexes(),
userRepository.ensureIndexes(), userRepository.ensureIndexes(),
]); ]);
@@ -65,6 +68,7 @@ async function mongoPlugin(fastify, { config }) {
fastify.decorate('mongoClient', client); fastify.decorate('mongoClient', client);
fastify.decorate('db', db); fastify.decorate('db', db);
fastify.decorate('allowedEmailRepository', allowedEmailRepository); fastify.decorate('allowedEmailRepository', allowedEmailRepository);
fastify.decorate('groupRepository', groupRepository);
fastify.decorate('recipeRepository', recipeRepository); fastify.decorate('recipeRepository', recipeRepository);
fastify.decorate('userRepository', userRepository); fastify.decorate('userRepository', userRepository);
+24
View File
@@ -0,0 +1,24 @@
export class GroupRepository {
constructor(db, collectionName = 'groups') {
this.collection = db.collection(collectionName);
}
async ensureIndexes() {
await this.collection.createIndex({ memberGoogleSubs: 1 });
}
async listMemberGoogleSubs(googleSub) {
const groups = await this.collection
.find(
{ memberGoogleSubs: googleSub },
{ projection: { _id: 0, memberGoogleSubs: 1 } },
)
.toArray();
return [...new Set(
groups
.flatMap((group) => group.memberGoogleSubs ?? [])
.filter((memberGoogleSub) => typeof memberGoogleSub === 'string'),
)];
}
}
+12 -1
View File
@@ -16,9 +16,13 @@ export class RecipeRepository {
} }
async listByOwner(ownerGoogleSub) { async listByOwner(ownerGoogleSub) {
return this.listByOwners([ownerGoogleSub]);
}
async listByOwners(ownerGoogleSubs) {
return this.collection return this.collection
.find( .find(
{ ownerGoogleSub }, { ownerGoogleSub: { $in: ownerGoogleSubs } },
{ {
projection: { projection: {
'source.rawText': 0, 'source.rawText': 0,
@@ -34,6 +38,13 @@ export class RecipeRepository {
return this.collection.findOne({ _id: recipeId, ownerGoogleSub }); return this.collection.findOne({ _id: recipeId, ownerGoogleSub });
} }
async findByIdForOwners(ownerGoogleSubs, recipeId) {
return this.collection.findOne({
_id: recipeId,
ownerGoogleSub: { $in: ownerGoogleSubs },
});
}
async findBySource(ownerGoogleSub, platform, sourceId) { async findBySource(ownerGoogleSub, platform, sourceId) {
return this.collection.findOne({ return this.collection.findOne({
ownerGoogleSub, ownerGoogleSub,
+14 -4
View File
@@ -29,13 +29,23 @@ function verifiedSource(source) {
return source; return source;
} }
async function visibleOwnerGoogleSubs(fastify, googleSub) {
const groupMembers = await fastify.groupRepository.listMemberGoogleSubs(googleSub);
return [...new Set([googleSub, ...groupMembers])];
}
export default async function recipeRoutes(fastify) { export default async function recipeRoutes(fastify) {
fastify.get('/api/recipes', { preHandler: fastify.authenticate }, async (request) => ({ fastify.get('/api/recipes', { preHandler: fastify.authenticate }, async (request) => {
recipes: await fastify.recipeRepository.listByOwner(request.user.sub), const ownerGoogleSubs = await visibleOwnerGoogleSubs(fastify, request.user.sub);
})); return { recipes: await fastify.recipeRepository.listByOwners(ownerGoogleSubs) };
});
fastify.get('/api/recipes/:id', { preHandler: fastify.authenticate }, async (request) => { fastify.get('/api/recipes/:id', { preHandler: fastify.authenticate }, async (request) => {
const recipe = await fastify.recipeRepository.findById(request.user.sub, request.params.id); const ownerGoogleSubs = await visibleOwnerGoogleSubs(fastify, request.user.sub);
const recipe = await fastify.recipeRepository.findByIdForOwners(
ownerGoogleSubs,
request.params.id,
);
if (!recipe) throw new NotFoundError('레시피를 찾을 수 없습니다.'); if (!recipe) throw new NotFoundError('레시피를 찾을 수 없습니다.');
return { recipe }; return { recipe };
}); });
+3
View File
@@ -13,6 +13,9 @@ const SYSTEM_PROMPT = `당신은 원문에서 레시피를 구조화하는 데
- 수량이 명확하지 않으면 null 또는 원문 표현을 유지합니다. - 수량이 명확하지 않으면 null 또는 원문 표현을 유지합니다.
- g, ml, 큰술, 작은술, 장, 개 등의 원문 단위를 유지합니다. - g, ml, 큰술, 작은술, 장, 개 등의 원문 단위를 유지합니다.
- 광고, 비즈니스 문의, SNS 링크, 해시태그 등 레시피와 무관한 내용을 제거합니다. - 광고, 비즈니스 문의, SNS 링크, 해시태그 등 레시피와 무관한 내용을 제거합니다.
- 원문이 주로 영어 또는 다른 외국어이면 title, summary, servings, ingredientGroups, steps, tips를 자연스러운 한국어로 번역합니다.
- 번역할 때 재료와 요리의 고유명사는 의미가 달라지지 않게 유지하고, 수량, 온도, 시간 값은 변환하지 않습니다.
- 원문이 이미 한국어이면 불필요하게 문체나 표현을 바꾸지 않습니다.
- YouTube timestamp는 원문 transcript에서 확인되는 경우에만 초 단위 숫자로 기록합니다. - YouTube timestamp는 원문 transcript에서 확인되는 경우에만 초 단위 숫자로 기록합니다.
- Instagram 단계의 timestampSec은 null입니다. - Instagram 단계의 timestampSec은 null입니다.
- 원문에 재료 그룹 이름이 없으면 "재료"를 사용합니다. ingredientGroups[].name은 null이 될 수 없습니다. - 원문에 재료 그룹 이름이 없으면 "재료"를 사용합니다. ingredientGroups[].name은 null이 될 수 없습니다.
+56 -1
View File
@@ -6,15 +6,17 @@ import { SESSION_COOKIE_NAME } from '../src/plugins/auth.js';
import { import {
createTestConfig, createTestConfig,
FakeAllowedEmailRepository, FakeAllowedEmailRepository,
FakeGroupRepository,
FakeRecipeRepository, FakeRecipeRepository,
fakeUserRepository, fakeUserRepository,
recipeDraft, recipeDraft,
source, source,
} from './helpers/fakes.js'; } from './helpers/fakes.js';
function createDependencies(recipeRepository) { function createDependencies(recipeRepository, groupRepository = new FakeGroupRepository()) {
return { return {
allowedEmailRepository: new FakeAllowedEmailRepository(), allowedEmailRepository: new FakeAllowedEmailRepository(),
groupRepository,
recipeRepository, recipeRepository,
userRepository: fakeUserRepository, userRepository: fakeUserRepository,
sourceExtractor: { extract: async () => source }, sourceExtractor: { extract: async () => source },
@@ -336,3 +338,56 @@ test('import preview와 소유자별 Recipe CRUD가 이어진다', async (t) =>
}); });
assert.equal(deleteResponse.statusCode, 204); assert.equal(deleteResponse.statusCode, 204);
}); });
test('같은 그룹 사용자는 레시피를 함께 조회하지만 작성자만 수정하고 삭제한다', async (t) => {
const recipeRepository = new FakeRecipeRepository();
const groupRepository = new FakeGroupRepository([
{ memberGoogleSubs: ['google-user-1', 'google-user-2'] },
]);
const app = await buildApp({
config: createTestConfig(),
dependencies: createDependencies(recipeRepository, groupRepository),
});
t.after(() => app.close());
const createResponse = await app.inject({
method: 'POST',
url: '/api/recipes',
headers: { cookie: authCookie(app, 'google-user-1') },
payload: { recipe: recipeDraft, source },
});
assert.equal(createResponse.statusCode, 201);
const created = createResponse.json().recipe;
const sharedListResponse = await app.inject({
method: 'GET',
url: '/api/recipes',
headers: { cookie: authCookie(app, 'google-user-2') },
});
assert.deepEqual(
sharedListResponse.json().recipes.map((recipe) => recipe._id),
[created._id],
);
const sharedDetailResponse = await app.inject({
method: 'GET',
url: `/api/recipes/${created._id}`,
headers: { cookie: authCookie(app, 'google-user-2') },
});
assert.equal(sharedDetailResponse.statusCode, 200);
const updateResponse = await app.inject({
method: 'PATCH',
url: `/api/recipes/${created._id}`,
headers: { cookie: authCookie(app, 'google-user-2') },
payload: { title: '다른 사용자의 수정' },
});
assert.equal(updateResponse.statusCode, 404);
const deleteResponse = await app.inject({
method: 'DELETE',
url: `/api/recipes/${created._id}`,
headers: { cookie: authCookie(app, 'google-user-2') },
});
assert.equal(deleteResponse.statusCode, 404);
});
+23 -1
View File
@@ -4,7 +4,12 @@ export class FakeRecipeRepository {
} }
async listByOwner(ownerGoogleSub) { async listByOwner(ownerGoogleSub) {
return [...this.recipes.values()].filter((recipe) => recipe.ownerGoogleSub === ownerGoogleSub); return this.listByOwners([ownerGoogleSub]);
}
async listByOwners(ownerGoogleSubs) {
return [...this.recipes.values()]
.filter((recipe) => ownerGoogleSubs.includes(recipe.ownerGoogleSub));
} }
async findById(ownerGoogleSub, recipeId) { async findById(ownerGoogleSub, recipeId) {
@@ -12,6 +17,11 @@ export class FakeRecipeRepository {
return recipe?.ownerGoogleSub === ownerGoogleSub ? recipe : null; 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) { async findBySource(ownerGoogleSub, platform, sourceId) {
return [...this.recipes.values()].find((recipe) => ( return [...this.recipes.values()].find((recipe) => (
recipe.ownerGoogleSub === ownerGoogleSub recipe.ownerGoogleSub === ownerGoogleSub
@@ -50,6 +60,18 @@ export class FakeRecipeRepository {
} }
} }
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 { export class FakeAllowedEmailRepository {
constructor(accounts = [ constructor(accounts = [
{ email: 'google-user-1@example.com', canManageAccess: true }, { email: 'google-user-1@example.com', canManageAccess: true },
+2
View File
@@ -23,6 +23,7 @@ test('Windows에서 개발용 MongoDB와 로컬 이미지 프로필을 자동
assert.equal(config.mongoFallbackUri, 'mongodb://172.16.0.7:27017/our_recipe_atlas'); assert.equal(config.mongoFallbackUri, 'mongodb://172.16.0.7:27017/our_recipe_atlas');
assert.deepEqual(config.mongoCollections, { assert.deepEqual(config.mongoCollections, {
allowedEmails: 'allowed_google_emails', allowedEmails: 'allowed_google_emails',
groups: 'groups_dev',
recipes: 'recipes_dev', recipes: 'recipes_dev',
users: 'users_dev', users: 'users_dev',
}); });
@@ -43,6 +44,7 @@ test('OS 프로필이 환경파일의 개발·운영 선택값보다 우선한
assert.equal(linuxConfig.mongoUri, 'mongodb://192.168.0.240:27017/our_recipe_atlas'); assert.equal(linuxConfig.mongoUri, 'mongodb://192.168.0.240:27017/our_recipe_atlas');
assert.deepEqual(linuxConfig.mongoCollections, { assert.deepEqual(linuxConfig.mongoCollections, {
allowedEmails: 'allowed_google_emails', allowedEmails: 'allowed_google_emails',
groups: 'groups',
recipes: 'recipes', recipes: 'recipes',
users: 'users', users: 'users',
}); });
+2
View File
@@ -71,5 +71,7 @@ test('MiniMax 사고 과정 분리와 충분한 출력 한도를 요청한다',
assert.equal(request.max_completion_tokens, 8192); assert.equal(request.max_completion_tokens, 8192);
assert.match(request.messages[0].content, /최대 3개/); assert.match(request.messages[0].content, /최대 3개/);
assert.match(request.messages[0].content, /한식, 중식/); assert.match(request.messages[0].content, /한식, 중식/);
assert.match(request.messages[0].content, /외국어.*자연스러운 한국어로 번역/);
assert.match(request.messages[0].content, /수량, 온도, 시간 값은 변환하지 않습니다/);
assert.equal(result.ingredientGroups[0].name, '재료'); assert.equal(result.ingredientGroups[0].name, '재료');
}); });