Skip to content

Commit e874a66

Browse files
authored
Merge pull request #308 from thoth-pub/feature/app-csv-d11-a
APP-CSV-D11-A: block published-status rows in CSV import (#281) Reviewed and approved at 50e4ab9; MERGE AUTH on #281 (comment 6767436833). Refs #281
2 parents 73494ca + 50e4ab9 commit e874a66

30 files changed

Lines changed: 1620 additions & 56 deletions

‎public/templates/template.csv‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
publisher,work_type,work_status,title,subtitle,edition,publication_date,withdrawn_date,place_of_publication,cover_url,doi,page_count,page_breakdown,image_count,table_count,audio_count,video_count,license,copyright_holder,landing_page,short_abstract,long_abstract,contribution_1_first_name,contribution_1_surname,contribution_1_role,contribution_1_biography,contribution_1_orcid,contribution_1_website,contribution_1_affiliation_position,contribution_1_affiliation_institution_name,contribution_1_affiliation_institution_ror,contribution_2_first_name,contribution_2_surname,contribution_2_role,contribution_2_biography,contribution_2_orcid,contribution_2_website,contribution_2_affiliation_position,contribution_2_affiliation_institution_name,contribution_2_affiliation_institution_ror,contribution_3_first_name,contribution_3_surname,contribution_3_role,contribution_3_biography,contribution_3_orcid,contribution_3_website,contribution_3_affiliation_position,contribution_3_affiliation_institution_name,contribution_3_affiliation_institution_ror,contribution_4_first_name,contribution_4_surname,contribution_4_role,contribution_4_biography,contribution_4_orcid,contribution_4_website,contribution_4_affiliation_position,contribution_4_affiliation_institution_name,contribution_4_affiliation_institution_ror,contribution_5_first_name,contribution_5_surname,contribution_5_role,contribution_5_biography,contribution_5_orcid,contribution_5_website,contribution_5_affiliation_position,contribution_5_affiliation_institution_name,contribution_5_affiliation_institution_ror,original_language,translated_from_language,translated_into_language,thema_subjects,bic_subjects,bisac_subjects,keywords,publication_paperback_isbn,publication_paperback_price_1_currency_code,publication_paperback_price_1_unit_price,publication_paperback_price_2_currency_code,publication_paperback_price_2_unit_price,publication_hardback_isbn,publication_hardback_price_1_currency_code,publication_hardback_price_1_unit_price,publication_hardback_price_2_currency_code,publication_hardback_price_2_unit_price,publication_pdf_isbn,publication_pdf_location_landing_page,publication_pdf_location_full_text_url,publication_pdf_location_platform,publication_epub_isbn,publication_epub_location_landing_page,publication_epub_location_full_text_url,publication_epub_location_platform,series_name,series_issn,series_issue_number
2-
My Publisher,Edited Book,Active,My book title,My book subtitle,1,2024-01-01,,"Earth, Milky Way",https://my.publisher.website/book/my-book/cover.jpg,10.12345/11221122,302,xxiv+278,12,7,2,5,https://creativecommons.org/licenses/by-nc/4.0/,John Doe,https://my.publisher.website/book/my-book,This is the short abstract of my book,This is the long abstract of my book,John K.,Doe,Author,This is the biography of the author of my book.,0000-0001-6365-5189,https://www.johndoe.org,Professor,Harvard University,03vek6s52,Alan,Smithee-Thomas III,Editor,This is the biography of the editor of my book.,0000-0001-1112-222X,,,,,J. Emily,Carter,Translator,,,,,,,James Martin,Holden,Introduction By,,,,,,,Olivia,Parker,Preface By,,,,,,,ENG,,SPA,"FYM,QDTK,QDHR5","HPK,HPCF3","FIC057000,PHI014000","embodiment,philosophy,media theory",9789800000007,USD,6.99,AUD,9.99,9789800000014,USD,7.99,AUD,12.99,9789800000021,https://my.publisher.com/book/my-book/pdf_landing_page,https://my.publisher.com/book/my-book/pdf_fulltext.pdf,Publisher Website,9789800000038,https://my.publisher.com/book/my-book/epub_landing_page,https://my.publisher.com/book/my-book/epub_fulltext.epub,Publisher Website,My Series Name,1122-1122,3
2+
My Publisher,Edited Book,Forthcoming,My book title,My book subtitle,1,,,"Earth, Milky Way",https://my.publisher.website/book/my-book/cover.jpg,10.12345/11221122,302,xxiv+278,12,7,2,5,https://creativecommons.org/licenses/by-nc/4.0/,John Doe,https://my.publisher.website/book/my-book,This is the short abstract of my book,This is the long abstract of my book,John K.,Doe,Author,This is the biography of the author of my book.,0000-0001-6365-5189,https://www.johndoe.org,Professor,Harvard University,03vek6s52,Alan,Smithee-Thomas III,Editor,This is the biography of the editor of my book.,0000-0001-1112-222X,,,,,J. Emily,Carter,Translator,,,,,,,James Martin,Holden,Introduction By,,,,,,,Olivia,Parker,Preface By,,,,,,,ENG,,SPA,"FYM,QDTK,QDHR5","HPK,HPCF3","FIC057000,PHI014000","embodiment,philosophy,media theory",9789800000007,USD,6.99,AUD,9.99,9789800000014,USD,7.99,AUD,12.99,9789800000021,https://my.publisher.com/book/my-book/pdf_landing_page,https://my.publisher.com/book/my-book/pdf_fulltext.pdf,Publisher Website,9789800000038,https://my.publisher.com/book/my-book/epub_landing_page,https://my.publisher.com/book/my-book/epub_fulltext.epub,Publisher Website,My Series Name,1122-1122,3

‎src/entities/work/api/work.service.test.ts‎

Lines changed: 234 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import { SeriesService } from '../../series';
4343
import type { SeriesEntity } from '../../series/model/series.types';
4444
import { SubjectService } from '../../subject/api/subject.service';
4545
import { TitleService } from '../../title/api/title.service';
46-
import { ImportExecutionError } from '../model/import-execution.error';
46+
import { CsvImportPreExecutionRejection, ImportExecutionError } from '../model/import-execution.error';
4747
import { WorkDtoMapper } from '../model/work.mapper';
4848
import type { WorkDto, WorkEntity } from '../model/work.types';
4949
import { WorkService } from './work.service';
@@ -1356,6 +1356,211 @@ describe('bulkCreateWorks execution observer', () => {
13561356
* These run the real ContributionService against a mocked API so the assertions are about the
13571357
* mutations an import would actually send.
13581358
*/
1359+
/**
1360+
* APP-CSV-D11-A (thoth-app#281): a CSV plan holding a Work or chapter with a published status is refused whole, before
1361+
* the first creation mutation and before the first progress reading. It is a temporary CSV bulk-import safety
1362+
* restriction: the service takes no role, so no caller - a superuser included - is excepted from it.
1363+
*/
1364+
describe('bulkCreateWorks published-status guard (APP-CSV-D11-A)', () => {
1365+
const { Active, Withdrawn, Superseded, Forthcoming, Cancelled, PostponedIndefinitely } = WorkStatuses.enum;
1366+
1367+
let workService: WorkService;
1368+
let mutation: ReturnType<typeof vi.fn>;
1369+
let seriesService: { createSeries: ReturnType<typeof vi.fn>; createIssue: ReturnType<typeof vi.fn> };
1370+
let createWorkSpy: ReturnType<typeof vi.spyOn>;
1371+
let createChapterSpy: ReturnType<typeof vi.spyOn>;
1372+
1373+
const titled = (id: string, title: string, extra: Partial<WorkEntity> = {}): WorkEntity =>
1374+
getDefaultWork({ id, titles: [getDefaultTitle({ title })], ...extra });
1375+
const chapterOf = (id: string, relationId: string, extra: Partial<WorkEntity> = {}): WorkEntity => ({
1376+
...titled(id, `Chapter ${id}`, extra),
1377+
relationId,
1378+
});
1379+
const planOf = (works: WorkEntity[], chapters: WorkEntity[] = [], series: SeriesImportPlan = []): ImportPlan => ({
1380+
works,
1381+
chapters,
1382+
series,
1383+
});
1384+
const inSeries = (...workIds: string[]): SeriesImportPlan => [
1385+
{
1386+
name: 'Arc Companions',
1387+
target: {
1388+
kind: 'proposed',
1389+
series: { name: 'Arc Companions', imprintId: 'imprint-a', type: SeriesTypes.enum.BookSeries },
1390+
},
1391+
members: workIds.map((workId, index) => ({ workId, orderNumber: index + 1 })),
1392+
},
1393+
];
1394+
1395+
/** Runs a plan that must be refused, and hands back what it was refused with. */
1396+
const refusal = async (plan: ImportPlan, onProgress = vi.fn()) => {
1397+
const error = await workService.bulkCreateWorks(plan, { onProgress }).then(
1398+
() => null,
1399+
(thrown: unknown) => thrown,
1400+
);
1401+
1402+
expect(error).toBeInstanceOf(CsvImportPreExecutionRejection);
1403+
expect(error).not.toBeInstanceOf(ImportExecutionError);
1404+
1405+
return { error: error as CsvImportPreExecutionRejection, onProgress };
1406+
};
1407+
1408+
/** Nothing was sent: no GraphQL mutation at all, and none of the creating service methods was reached. */
1409+
const expectNothingSent = () => {
1410+
expect(mutation).not.toHaveBeenCalled();
1411+
expect(createWorkSpy).not.toHaveBeenCalled();
1412+
expect(createChapterSpy).not.toHaveBeenCalled();
1413+
expect(seriesService.createSeries).not.toHaveBeenCalled();
1414+
expect(seriesService.createIssue).not.toHaveBeenCalled();
1415+
};
1416+
1417+
beforeEach(() => {
1418+
mutation = vi.fn();
1419+
seriesService = {
1420+
createSeries: vi.fn().mockImplementation(async (data) => ({ ...data, id: 'created-series' })),
1421+
createIssue: vi.fn().mockResolvedValue({}),
1422+
};
1423+
1424+
workService = new WorkService({
1425+
graphqlService: { mutation, query: vi.fn() } as unknown as GraphqlService,
1426+
fundingService: {} as unknown as FundingService,
1427+
subjectService: {} as unknown as SubjectService,
1428+
contributionService: {} as unknown as ContributionService,
1429+
publicationService: {} as unknown as PublicationService,
1430+
languageService: {} as unknown as LanguageService,
1431+
seriesService: seriesService as unknown as SeriesService,
1432+
referenceService: {} as unknown as ReferenceService,
1433+
titleService: {} as unknown as TitleService,
1434+
abstractService: {} as unknown as AbstractService,
1435+
});
1436+
1437+
createWorkSpy = vi
1438+
.spyOn(workService, 'createWork')
1439+
.mockImplementation(async (work: WorkEntity) => ({ ...work, id: `created-${work.id}` }));
1440+
createChapterSpy = vi.spyOn(workService, 'createChapter').mockResolvedValue(getDefaultWork({ id: 'chapter' }));
1441+
});
1442+
1443+
it.each([Active, Withdrawn, Superseded])(
1444+
'refuses a plan whose last Work is %s before any mutation or progress reading, leaving the plan as it was',
1445+
async (status) => {
1446+
const works = [
1447+
titled('w1', 'One'),
1448+
titled('w2', 'Two'),
1449+
titled('w3', 'Three', { status, doi: 'https://doi.org/10.1/three', reference: 'ref-3' }),
1450+
];
1451+
const plan = planOf(works, [], inSeries('w1', 'w2', 'w3'));
1452+
const before = structuredClone(plan);
1453+
1454+
const { error, onProgress } = await refusal(plan);
1455+
1456+
expect(error.context).toEqual({
1457+
total: 3,
1458+
violations: [{ entity: 'work', position: 3, status, title: 'Three', reference: 'https://doi.org/10.1/three' }],
1459+
});
1460+
expect(onProgress).not.toHaveBeenCalled();
1461+
expectNothingSent();
1462+
expect(plan).toEqual(before);
1463+
},
1464+
);
1465+
1466+
it('names every published Work in plan order, with its reference when it has no DOI', async () => {
1467+
const { error } = await refusal(
1468+
planOf([
1469+
titled('w1', 'One', { status: Superseded, reference: 'ref-1' }),
1470+
titled('w2', 'Two'),
1471+
titled('w3', 'Three', { status: Active }),
1472+
]),
1473+
);
1474+
1475+
expect(error.context.violations).toEqual([
1476+
{ entity: 'work', position: 1, status: Superseded, title: 'One', reference: 'ref-1' },
1477+
{ entity: 'work', position: 3, status: Active, title: 'Three' },
1478+
]);
1479+
expect(error.message).toBe(
1480+
'CSV bulk import refused before execution because the plan contains published-status Works (2 violations), so nothing was created.',
1481+
);
1482+
expectNothingSent();
1483+
});
1484+
1485+
it('refuses a published chapter before any Work, Series or Issue mutation, keeping the child violation', async () => {
1486+
const works = [titled('w1', 'One'), titled('w2', 'Two')];
1487+
const chapters = [chapterOf('c1', 'w2'), chapterOf('c2', 'w2', { status: Withdrawn })];
1488+
const plan = planOf(works, chapters, inSeries('w1', 'w2'));
1489+
const before = structuredClone(plan);
1490+
1491+
const { error, onProgress } = await refusal(plan);
1492+
1493+
expect(error.context).toEqual({
1494+
total: 2,
1495+
violations: [{ entity: 'chapter', position: 2, status: Withdrawn, title: 'Chapter c2', parentPosition: 2 }],
1496+
});
1497+
expect(onProgress).not.toHaveBeenCalled();
1498+
expectNothingSent();
1499+
expect(plan).toEqual(before);
1500+
});
1501+
1502+
it('names an unexpected published chapter that belongs to no Work of the plan', async () => {
1503+
const { error } = await refusal(planOf([titled('w1', 'One')], [chapterOf('c1', 'nowhere', { status: Active })]));
1504+
1505+
expect(error.context.violations).toEqual([
1506+
{ entity: 'chapter', position: 1, status: Active, title: 'Chapter c1', parentPosition: null },
1507+
]);
1508+
expectNothingSent();
1509+
});
1510+
1511+
it('lists published Works before published chapters', async () => {
1512+
const { error } = await refusal(
1513+
planOf(
1514+
[titled('w1', 'One'), titled('w2', 'Two', { status: Active })],
1515+
[chapterOf('c1', 'w1', { status: Superseded })],
1516+
),
1517+
);
1518+
1519+
expect(error.context.violations.map(({ entity, position }) => `${entity}:${position}`)).toEqual([
1520+
'work:2',
1521+
'chapter:1',
1522+
]);
1523+
});
1524+
1525+
it('does not refuse a plan for its chapters alone, nor for a non-published chapter', async () => {
1526+
await workService.bulkCreateWorks(
1527+
planOf([titled('w1', 'One')], [chapterOf('c1', 'w1'), chapterOf('c2', 'w1', { status: Cancelled })]),
1528+
);
1529+
1530+
expect(createWorkSpy).toHaveBeenCalledTimes(1);
1531+
expect(createChapterSpy).toHaveBeenCalledTimes(2);
1532+
});
1533+
1534+
it.each([Forthcoming, Cancelled, PostponedIndefinitely])(
1535+
'runs a plan of %s Works exactly as before',
1536+
async (status) => {
1537+
const onProgress = vi.fn();
1538+
1539+
await workService.bulkCreateWorks(planOf([titled('w1', 'One', { status }), titled('w2', 'Two', { status })]), {
1540+
onProgress,
1541+
});
1542+
1543+
expect(createWorkSpy).toHaveBeenCalledTimes(2);
1544+
expect(onProgress).toHaveBeenCalledTimes(2);
1545+
},
1546+
);
1547+
1548+
it('reads the status headed to the API, not anything a parser decided earlier', async () => {
1549+
// A plan that passed preflight as FORTHCOMING but whose Work now says ACTIVE is refused on what it now says.
1550+
const plan = planOf([titled('w1', 'One')]);
1551+
plan.works[0] = { ...plan.works[0], status: Active };
1552+
1553+
await refusal(plan);
1554+
1555+
expectNothingSent();
1556+
});
1557+
1558+
it('takes no role, so it has no superuser or work-lifecycle exception', () => {
1559+
// The guard reads the plan alone: the service signature carries no user, role or permission to except anyone by.
1560+
expect(workService.bulkCreateWorks).toHaveLength(2);
1561+
});
1562+
});
1563+
13591564
describe('bulkCreateWorks contributor identity (issue #135)', () => {
13601565
const ORCID = 'https://orcid.org/0000-0001-6365-5189';
13611566
const OTHER_ORCID = 'https://orcid.org/0000-0002-1825-0097';
@@ -2526,6 +2731,34 @@ describe('bulkCreateWorks ONIX execution units (thoth-app#187)', () => {
25262731
expect(ops()).toEqual(['CreateWork']);
25272732
});
25282733

2734+
it('runs a published-status ONIX plan exactly as before: the CSV restriction never reaches it (APP-CSV-D11-A)', async () => {
2735+
await run(
2736+
onixPlan({
2737+
works: [
2738+
work('w1', { status: WorkStatuses.enum.Active }),
2739+
work('w2', { status: WorkStatuses.enum.Withdrawn }),
2740+
work('w3', { status: WorkStatuses.enum.Superseded }),
2741+
],
2742+
units: [
2743+
unit(1, planned('w1'), [createWork('w1')]),
2744+
unit(2, planned('w2'), [createWork('w2')]),
2745+
unit(3, planned('w3'), [createWork('w3')]),
2746+
],
2747+
}),
2748+
);
2749+
2750+
expect(writesOf('CreateWork').map(({ data }) => (data as { reference: string }).reference)).toEqual([
2751+
'w1',
2752+
'w2',
2753+
'w3',
2754+
]);
2755+
expect(progress.map(({ completed, current }) => [completed, current.position])).toEqual([
2756+
[0, 1],
2757+
[1, 2],
2758+
[2, 3],
2759+
]);
2760+
});
2761+
25292762
it('stops on the failed unit: earlier units stay completed and untouched, later ones never start', async () => {
25302763
overrides.set('CreateWork', ({ data }) => {
25312764
const { reference } = data as { reference: string };

0 commit comments

Comments
 (0)