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
+56 -1
View File
@@ -6,15 +6,17 @@ import { SESSION_COOKIE_NAME } from '../src/plugins/auth.js';
import {
createTestConfig,
FakeAllowedEmailRepository,
FakeGroupRepository,
FakeRecipeRepository,
fakeUserRepository,
recipeDraft,
source,
} from './helpers/fakes.js';
function createDependencies(recipeRepository) {
function createDependencies(recipeRepository, groupRepository = new FakeGroupRepository()) {
return {
allowedEmailRepository: new FakeAllowedEmailRepository(),
groupRepository,
recipeRepository,
userRepository: fakeUserRepository,
sourceExtractor: { extract: async () => source },
@@ -336,3 +338,56 @@ test('import preview와 소유자별 Recipe CRUD가 이어진다', async (t) =>
});
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) {
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) {
@@ -12,6 +17,11 @@ export class FakeRecipeRepository {
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
@@ -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 {
constructor(accounts = [
{ 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.deepEqual(config.mongoCollections, {
allowedEmails: 'allowed_google_emails',
groups: 'groups_dev',
recipes: 'recipes_dev',
users: 'users_dev',
});
@@ -43,6 +44,7 @@ test('OS 프로필이 환경파일의 개발·운영 선택값보다 우선한
assert.equal(linuxConfig.mongoUri, 'mongodb://192.168.0.240:27017/our_recipe_atlas');
assert.deepEqual(linuxConfig.mongoCollections, {
allowedEmails: 'allowed_google_emails',
groups: 'groups',
recipes: 'recipes',
users: 'users',
});
+2
View File
@@ -71,5 +71,7 @@ test('MiniMax 사고 과정 분리와 충분한 출력 한도를 요청한다',
assert.equal(request.max_completion_tokens, 8192);
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.equal(result.ingredientGroups[0].name, '재료');
});