feat: add batched recipe import queue
This commit is contained in:
+112
-2
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user