feat: add batched recipe import queue

This commit is contained in:
2026-08-28 11:09:36 +09:00
parent 4491c91455
commit f65e660184
19 changed files with 1080 additions and 262 deletions
+112 -2
View File
@@ -7,20 +7,29 @@ import {
createTestConfig,
FakeAllowedEmailRepository,
FakeGroupRepository,
FakeImportJobRepository,
FakeRecipeRepository,
fakeUserRepository,
recipeDraft,
source,
} from './helpers/fakes.js';
function createDependencies(recipeRepository, groupRepository = new FakeGroupRepository()) {
function createDependencies(
recipeRepository,
groupRepository = new FakeGroupRepository(),
importJobRepository = new FakeImportJobRepository(),
) {
return {
allowedEmailRepository: new FakeAllowedEmailRepository(),
groupRepository,
importJobRepository,
recipeRepository,
userRepository: fakeUserRepository,
sourceExtractor: { extract: async () => source },
recipeParser: { parse: async () => recipeDraft },
recipeParser: {
parse: async () => recipeDraft,
parseMany: async (entries) => entries.map(({ jobId }) => ({ jobId, recipe: recipeDraft })),
},
imageStorage: {
saveFromUrl: async (id) => `recipes/${id}/cover.webp`,
removeRecipe: async () => {},
@@ -74,6 +83,10 @@ test('관리자는 이메일만으로 Google 로그인 허용 계정을 관리
assert.match(pageResponse.body, /id="site-header"/);
assert.match(pageResponse.body, /id="access-overlay"/);
assert.match(pageResponse.body, /id="scroll-to-top"/);
assert.match(pageResponse.body, /id="source-urls"/);
assert.match(pageResponse.body, /id="import-queue"/);
assert.match(pageResponse.body, /id="process-import-jobs"/);
assert.doesNotMatch(pageResponse.body, /id="preview-section"/);
const appBarResponse = await app.inject({ method: 'GET', url: '/js/app-bar.js' });
assert.equal(appBarResponse.statusCode, 200);
@@ -339,6 +352,103 @@ test('import preview와 소유자별 Recipe CRUD가 이어진다', async (t) =>
assert.equal(deleteResponse.statusCode, 204);
});
test('여러 URL을 대기열에 넣고 비동기로 일괄 처리하며 완료 작업은 숨긴다', async (t) => {
const recipeRepository = new FakeRecipeRepository();
const importJobRepository = new FakeImportJobRepository();
const dependencies = createDependencies(
recipeRepository,
new FakeGroupRepository(),
importJobRepository,
);
const app = await buildApp({ config: createTestConfig(), dependencies });
t.after(() => app.close());
const cookie = authCookie(app);
const enqueueResponse = await app.inject({
method: 'POST',
url: '/api/import/jobs',
headers: { cookie },
payload: { urls: [source.sourceUrl, source.sourceUrl] },
});
assert.equal(enqueueResponse.statusCode, 201);
assert.equal(enqueueResponse.json().addedCount, 1);
assert.equal(enqueueResponse.json().duplicateCount, 1);
assert.equal(enqueueResponse.json().jobs[0].status, 'pending');
const processResponse = await app.inject({
method: 'POST',
url: '/api/import/jobs/process',
headers: { cookie },
});
assert.equal(processResponse.statusCode, 202);
await app.importJobProcessor.waitForIdle('google-user-1');
const queueResponse = await app.inject({
method: 'GET',
url: '/api/import/jobs',
headers: { cookie },
});
assert.deepEqual(queueResponse.json().jobs, []);
const recipesResponse = await app.inject({
method: 'GET',
url: '/api/recipes',
headers: { cookie },
});
assert.equal(recipesResponse.json().recipes.length, 1);
const duplicateResponse = await app.inject({
method: 'POST',
url: '/api/import/jobs',
headers: { cookie },
payload: { urls: [source.sourceUrl] },
});
assert.equal(duplicateResponse.json().addedCount, 0);
assert.equal(duplicateResponse.json().duplicateCount, 1);
});
test('가져오기 실패 작업은 대기열에 남고 사용자가 재시도할 수 있다', async (t) => {
const dependencies = createDependencies(new FakeRecipeRepository());
dependencies.sourceExtractor = {
async extract() {
throw new Error('원문 추출 실패');
},
};
const app = await buildApp({ config: createTestConfig(), dependencies });
t.after(() => app.close());
const cookie = authCookie(app);
await app.inject({
method: 'POST',
url: '/api/import/jobs',
headers: { cookie },
payload: { urls: [source.sourceUrl] },
});
await app.inject({
method: 'POST',
url: '/api/import/jobs/process',
headers: { cookie },
});
await app.importJobProcessor.waitForIdle('google-user-1');
const failedResponse = await app.inject({
method: 'GET',
url: '/api/import/jobs',
headers: { cookie },
});
const [failedJob] = failedResponse.json().jobs;
assert.equal(failedJob.status, 'failed');
assert.equal(failedJob.error.message, '가져오기 작업을 처리하지 못했습니다.');
const retryResponse = await app.inject({
method: 'POST',
url: `/api/import/jobs/${failedJob.id}/retry`,
headers: { cookie },
});
assert.equal(retryResponse.statusCode, 200);
assert.equal(retryResponse.json().job.status, 'pending');
});
test('같은 그룹 사용자는 레시피를 함께 조회하지만 작성자만 수정하고 삭제한다', async (t) => {
const recipeRepository = new FakeRecipeRepository();
const groupRepository = new FakeGroupRepository([