diff --git a/src/migration/config-migration/codo-add-event-type.migration.spec.ts b/src/migration/config-migration/codo-add-event-type.migration.spec.ts new file mode 100644 index 0000000..04ded81 --- /dev/null +++ b/src/migration/config-migration/codo-add-event-type.migration.spec.ts @@ -0,0 +1,307 @@ +import { + buildTestContext, + DocStore, + runIdempotencyCheck, +} from '../testing/migration-idempotency.harness'; +import { codoAddEventType } from './codo-add-event-type.migration'; + +/** Minimal codo-like config without any event-related entries. */ +function baseSeed(): DocStore { + return { + 'app/Config:CONFIG_ENTITY': { + _id: 'Config:CONFIG_ENTITY', + data: { + navigationMenu: { + items: [ + { entityType: 'Child' }, + { entityType: 'ChildSchoolRelation' }, + { entityType: 'Note' }, + { entityType: 'Todo' }, + ], + }, + 'entity:Note': { + label: 'Notiz', + attributes: { + relatedEntities: { + dataType: 'entity', + additional: 'ChildSchoolRelation', + label: 'Patenschaft/en', + isArray: true, + }, + }, + }, + 'entity:Todo': { + label: 'Aufgabe', + attributes: { + relatedEntities: { + dataType: 'entity', + additional: ['Child', 'School', 'ChildSchoolRelation'], + isArray: true, + }, + }, + }, + 'view:mentor/:id': { + config: { + panels: [ + { title: 'Mentor:in', components: [{ component: 'Form' }] }, + { + title: 'Notizen & Aufgaben', + components: [ + { component: 'NotesRelatedToEntity' }, + { component: 'TodosRelatedToEntity' }, + ], + }, + ], + }, + }, + 'view:mentee/:id': { + config: { + panels: [ + { title: 'Mentee', components: [{ component: 'Form' }] }, + { + title: 'Notizen & Aufgaben', + components: [ + { component: 'NotesRelatedToEntity' }, + { component: 'TodosRelatedToEntity' }, + ], + }, + ], + }, + }, + 'view:matching': { component: 'MatchingEntities' }, + }, + }, + }; +} + +describe('codoAddEventType migration', () => { + it('should apply all changes on first run', async () => { + const store = baseSeed(); + const ctx = buildTestContext(store); + const result = await codoAddEventType.run(ctx); + + expect(result.changed).toBe(true); + expect(result.status).toBe('ok'); + + // Enum docs created + expect(store['app/ConfigurableEnum:artDerVeranstaltung']).toBeDefined(); + expect(store['app/ConfigurableEnum:attendance-status']).toBeDefined(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const config = store['app/Config:CONFIG_ENTITY'] as any; + + // Entity and view configs added + expect(config.data['entity:event']).toBeDefined(); + expect(config.data['entity:event'].label).toBe('Veranstaltung'); + expect(config.data['view:event']).toBeDefined(); + expect(config.data['view:event/:id']).toBeDefined(); + + // Note relatedEntities updated (string → array with event) + expect( + config.data['entity:Note'].attributes.relatedEntities.additional, + ).toEqual(['ChildSchoolRelation', 'event']); + + // Note relatedEntities label renamed + expect(config.data['entity:Note'].attributes.relatedEntities.label).toBe( + 'Sonstige verknüpfte Datensätze', + ); + + // Todo relatedEntities updated (array += event) + expect( + config.data['entity:Todo'].attributes.relatedEntities.additional, + ).toEqual(['Child', 'School', 'ChildSchoolRelation', 'event']); + + // Nav menu updated – event inserted before Note + const items = config.data.navigationMenu.items; + const eventIdx = items.findIndex((i) => i.entityType === 'event'); + const noteIdx = items.findIndex((i) => i.entityType === 'Note'); + expect(eventIdx).toBeGreaterThanOrEqual(0); + expect(noteIdx).toBeGreaterThan(eventIdx); + + // Veranstaltungen added to mentor view + const mentorPanels = config.data['view:mentor/:id'].config.panels; + const mentorTab = mentorPanels.find( + (p) => p.title === 'Notizen & Aufgaben', + ); + expect(mentorTab.components).toContainEqual( + expect.objectContaining({ + component: 'RelatedEntities', + config: expect.objectContaining({ entityType: 'event' }), + }), + ); + + // Veranstaltungen added to mentee view + const menteePanels = config.data['view:mentee/:id'].config.panels; + const menteeTab = menteePanels.find( + (p) => p.title === 'Notizen & Aufgaben', + ); + expect(menteeTab.components).toContainEqual( + expect.objectContaining({ + component: 'RelatedEntities', + config: expect.objectContaining({ entityType: 'event' }), + }), + ); + }); + + it('should be idempotent (second run reports no-change)', async () => { + const result = await runIdempotencyCheck(codoAddEventType, baseSeed()); + + expect(result.secondRunResult.changed).toBe(false); + expect(result.secondRunResult.status).toBe('no-change'); + expect(result.stateAfterSecondRun).toEqual(result.stateAfterFirstRun); + }); + + it('should handle missing config document', async () => { + const ctx = buildTestContext({}); + const result = await codoAddEventType.run(ctx); + + expect(result.status).toBe('failed'); + expect(result.changed).toBe(false); + }); + + it('should handle missing data object in config', async () => { + const store: DocStore = { + 'app/Config:CONFIG_ENTITY': { _id: 'Config:CONFIG_ENTITY' }, + }; + const ctx = buildTestContext(store); + const result = await codoAddEventType.run(ctx); + + expect(result.status).toBe('failed'); + expect(result.warnings).toContainEqual( + expect.stringContaining('no data object'), + ); + }); + + it('should skip existing enum docs without overwriting', async () => { + const store = baseSeed(); + store['app/ConfigurableEnum:artDerVeranstaltung'] = { + _id: 'ConfigurableEnum:artDerVeranstaltung', + values: [{ id: 'CUSTOM', label: 'Custom' }], + }; + store['app/ConfigurableEnum:attendance-status'] = { + _id: 'ConfigurableEnum:attendance-status', + values: [{ id: 'X', label: 'X' }], + }; + + const ctx = buildTestContext(store); + const result = await codoAddEventType.run(ctx); + + expect(result.changed).toBe(true); // Config changes still applied + + // Enum docs untouched + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const artEnum = store['app/ConfigurableEnum:artDerVeranstaltung'] as any; + expect(artEnum.values[0].id).toBe('CUSTOM'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const attEnum = store['app/ConfigurableEnum:attendance-status'] as any; + expect(attEnum.values[0].id).toBe('X'); + }); + + it('should skip existing entity:event without overwriting', async () => { + const store = baseSeed(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (store['app/Config:CONFIG_ENTITY'] as any).data['entity:event'] = { + label: 'Custom Event', + }; + + const ctx = buildTestContext(store); + const result = await codoAddEventType.run(ctx); + + expect(result.changed).toBe(true); // Other changes still applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const config = store['app/Config:CONFIG_ENTITY'] as any; + expect(config.data['entity:event'].label).toBe('Custom Event'); + }); + + it('should handle missing mentor/mentee views with warnings', async () => { + const store = baseSeed(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = (store['app/Config:CONFIG_ENTITY'] as any).data; + delete data['view:mentor/:id']; + delete data['view:mentee/:id']; + + const ctx = buildTestContext(store); + const result = await codoAddEventType.run(ctx); + + expect(result.changed).toBe(true); + expect(result.warnings).toContainEqual( + expect.stringContaining('view:mentor/:id'), + ); + expect(result.warnings).toContainEqual( + expect.stringContaining('view:mentee/:id'), + ); + }); + + it('should handle missing relatedEntities attribute with warning', async () => { + const store = baseSeed(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = (store['app/Config:CONFIG_ENTITY'] as any).data; + delete data['entity:Note'].attributes.relatedEntities; + + const ctx = buildTestContext(store); + const result = await codoAddEventType.run(ctx); + + expect(result.changed).toBe(true); + expect(result.warnings).toContainEqual( + expect.stringContaining('entity:Note'), + ); + }); + + it('should handle detail view without matching tab', async () => { + const store = baseSeed(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = (store['app/Config:CONFIG_ENTITY'] as any).data; + data['view:mentor/:id'].config.panels = [ + { title: 'Sonstiges', components: [{ component: 'Form' }] }, + ]; + + const ctx = buildTestContext(store); + const result = await codoAddEventType.run(ctx); + + expect(result.changed).toBe(true); + expect(result.warnings).toContainEqual( + expect.stringContaining('view:mentor/:id'), + ); + expect(result.warnings).toContainEqual( + expect.stringContaining('manual review'), + ); + }); + + it('should find tab by content-based fallback', async () => { + const store = baseSeed(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = (store['app/Config:CONFIG_ENTITY'] as any).data; + // Rename the tab title to something unusual + data['view:mentor/:id'].config.panels[1].title = 'Alles Mögliche'; + + const ctx = buildTestContext(store); + const result = await codoAddEventType.run(ctx); + + expect(result.changed).toBe(true); + // Should still find the tab via content-based fallback + const panel = data['view:mentor/:id'].config.panels[1]; + expect(panel.components).toContainEqual( + expect.objectContaining({ + component: 'RelatedEntities', + config: expect.objectContaining({ entityType: 'event' }), + }), + ); + }); + + it('should append event to end of nav menu when Note is absent', async () => { + const store = baseSeed(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = (store['app/Config:CONFIG_ENTITY'] as any).data; + data.navigationMenu.items = [ + { entityType: 'Child' }, + { entityType: 'Todo' }, + ]; + + const ctx = buildTestContext(store); + const result = await codoAddEventType.run(ctx); + + expect(result.changed).toBe(true); + const items = data.navigationMenu.items; + expect(items[items.length - 1]).toEqual({ entityType: 'event' }); + }); +}); diff --git a/src/migration/config-migration/codo-add-event-type.migration.ts b/src/migration/config-migration/codo-add-event-type.migration.ts new file mode 100644 index 0000000..90da5f2 --- /dev/null +++ b/src/migration/config-migration/codo-add-event-type.migration.ts @@ -0,0 +1,390 @@ +import { + failedMigrationResult, + MigrationDefinition, + MigrationResult, +} from '../migration-definition'; +import { CONFIG_DOC_PATH } from '../migrations'; + +// --- Enum document paths & templates --- + +const ENUM_ART_PATH = '/app/ConfigurableEnum:artDerVeranstaltung'; +const ENUM_ATTENDANCE_PATH = '/app/ConfigurableEnum:attendance-status'; + +const artDerVeranstaltungEnum = { + _id: 'ConfigurableEnum:artDerVeranstaltung', + values: [ + { id: '', label: '' }, + { id: 'WORKSHOP', label: 'Workshop' }, + { id: 'TREFFEN', label: 'Treffen' }, + { id: 'AUSFLUG', label: 'Ausflug' }, + ], +}; + +const attendanceStatusEnum = { + _id: 'ConfigurableEnum:attendance-status', + values: [ + { id: '', label: '' }, + { id: 'EINGELADEN', label: 'eingeladen' }, + { id: 'ANWESEND', label: 'anwesend' }, + { id: 'ABWESEND', label: 'abwesend' }, + ], +}; + +// --- Config entry templates --- + +const eventEntity = { + label: 'Veranstaltung', + route: 'event', + labelPlural: 'Veranstaltungen', + icon: 'calendar', + toStringAttributes: ['name'], + hasPII: false, + attributes: { + name: { label: 'Titel', dataType: 'string' }, + date: { label: 'Datum', dataType: 'date-only' }, + teilnehmer_innen: { + label: 'Teilnehmer:innen', + dataType: 'attendance', + additional: { + participant: { + dataType: 'entity', + additional: ['User', 'School', 'Child'], + }, + }, + isArray: true, + }, + anmerkungen: { label: 'Anmerkungen / Agenda', dataType: 'long-text' }, + verantwortliche: { + label: 'Verantwortliche', + dataType: 'entity', + additional: 'User', + isArray: true, + }, + artDerVeranstaltung: { + label: 'Art der Veranstaltung', + dataType: 'configurable-enum', + additional: 'artDerVeranstaltung', + }, + }, +}; + +const eventListView = { + component: 'EntityList', + config: { + entityType: 'event', + columnGroups: { + default: 'Overview', + mobile: 'Overview', + groups: [ + { + name: 'Übersicht', + columns: [ + 'date', + 'name', + 'artDerVeranstaltung', + 'teilnehmer_innen', + 'verantwortliche', + ], + }, + ], + }, + filters: [{ id: 'date' }, { id: 'verantwortliche' }], + }, +}; + +const eventDetailView = { + component: 'EntityDetails', + config: { + entityType: 'event', + panels: [ + { + title: 'Veranstaltung', + components: [ + { + title: '', + component: 'Form', + config: { + fieldGroups: [ + { + fields: [ + 'name', + 'date', + 'artDerVeranstaltung', + 'teilnehmer_innen', + 'anmerkungen', + 'verantwortliche', + ], + }, + ], + }, + }, + ], + }, + { + title: 'Notizen & Aufgaben', + components: [ + { + title: '', + component: 'NotesRelatedToEntity', + config: { + entityType: 'Note', + columns: [ + { id: 'date' }, + { id: 'subject' }, + { id: 'authors' }, + { id: 'category' }, + ], + }, + }, + { + title: 'Aufgaben', + component: 'TodosRelatedToEntity', + config: { + entityType: 'Todo', + columns: [ + { id: 'subject' }, + { id: 'deadline' }, + { id: 'startDate' }, + { id: 'assignedTo' }, + ], + }, + }, + ], + }, + ], + }, +}; + +const veranstaltungenComponent = { + title: 'Veranstaltungen', + component: 'RelatedEntities', + config: { + entityType: 'event', + columns: [{ id: 'date' }, { id: 'name' }, { id: 'verantwortliche' }], + }, +}; + +// --- Helper functions --- + +/* eslint-disable @typescript-eslint/no-explicit-any -- + Config JSON is deeply nested and untyped; any is pragmatic here. */ + +function appendEventToRelatedEntities( + data: Record, + entityKey: string, + warnings: string[], +): boolean { + const entity = data[entityKey]; + if (!entity) { + warnings.push(`${entityKey} not found, skipping relatedEntities update`); + return false; + } + const relatedEntities = entity.attributes?.relatedEntities; + if (!relatedEntities) { + warnings.push(`${entityKey} has no relatedEntities attribute, skipping`); + return false; + } + const { additional } = relatedEntities; + if (Array.isArray(additional)) { + if (additional.includes('event')) return false; + additional.push('event'); + return true; + } + if (typeof additional === 'string') { + relatedEntities.additional = [additional, 'event']; + return true; + } + warnings.push( + `${entityKey} relatedEntities.additional has unexpected type (${typeof additional}), skipping`, + ); + return false; +} + +function appendEventToNavMenu( + data: Record, + warnings: string[], +): boolean { + const menu = data.navigationMenu; + if (!menu || !Array.isArray(menu.items)) { + warnings.push('navigationMenu.items not found or not an array, skipping'); + return false; + } + if (menu.items.some((item: any) => item.entityType === 'event')) { + return false; + } + const noteIndex = menu.items.findIndex( + (item: any) => item.entityType === 'Note', + ); + if (noteIndex >= 0) { + menu.items.splice(noteIndex, 0, { entityType: 'event' }); + } else { + menu.items.push({ entityType: 'event' }); + } + return true; +} + +function addVeranstaltungenToDetailView( + data: Record, + viewKey: string, + warnings: string[], +): boolean { + const view = data[viewKey]; + if (!view) { + warnings.push(`${viewKey} not found, skipping Veranstaltungen component`); + return false; + } + const panels: any[] | undefined = view.config?.panels; + if (!Array.isArray(panels)) { + warnings.push(`${viewKey} has no panels array, skipping`); + return false; + } + + // Check if event component already exists anywhere in this view + for (const panel of panels) { + if (!Array.isArray(panel.components)) continue; + for (const comp of panel.components) { + if ( + comp.component === 'RelatedEntities' && + comp.config?.entityType === 'event' + ) { + return false; + } + } + } + + // Find target tab using layered anchor strategy + const targetPanel = + panels.find((p) => p.title === 'Notizen & Aufgaben') ?? + panels.find( + (p) => + typeof p.title === 'string' && + /notizen/i.test(p.title) && + /aufgaben/i.test(p.title), + ) ?? + panels.find( + (p) => + Array.isArray(p.components) && + p.components.some((c: any) => c.component === 'NotesRelatedToEntity') && + p.components.some((c: any) => c.component === 'TodosRelatedToEntity'), + ); + + if (!targetPanel) { + warnings.push( + `${viewKey}: no suitable tab found for Veranstaltungen component, needs manual review`, + ); + return false; + } + + if (!Array.isArray(targetPanel.components)) { + targetPanel.components = []; + } + targetPanel.components.push( + JSON.parse(JSON.stringify(veranstaltungenComponent)), + ); + return true; +} + +/* eslint-enable @typescript-eslint/no-explicit-any */ + +// --- Migration definition --- + +export const codoAddEventType: MigrationDefinition = { + id: 'codo-add-event-type', + description: + 'Add Event entity type, views, enums, and navigation entry to codo systems. Use with --category codo.', + + async run(ctx): Promise { + // Load config document first to fail fast if missing + let config: Record; + try { + config = (await ctx.couchdb.get(CONFIG_DOC_PATH)) as Record< + string, + unknown + >; + } catch (error: unknown) { + if ((error as { status?: number }).status === 404) { + return failedMigrationResult('Config document not found'); + } + throw error; + } + + const data = config.data as Record | undefined; + if (!data || typeof data !== 'object') { + return failedMigrationResult('Config document has no data object'); + } + + const warnings: string[] = []; + let anyChange = false; + + // Step 1: Add enum documents (separate from config doc) + if (await ctx.addDocIfMissing(ENUM_ART_PATH, artDerVeranstaltungEnum)) { + anyChange = true; + } + if (await ctx.addDocIfMissing(ENUM_ATTENDANCE_PATH, attendanceStatusEnum)) { + anyChange = true; + } + + // Steps 2–5 operate on the config data object + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const d = data as Record; + let configChanged = false; + + // Step 2: Add entity:event, view:event, view:event/:id + const configEntries: [string, unknown][] = [ + ['entity:event', eventEntity], + ['view:event', eventListView], + ['view:event/:id', eventDetailView], + ]; + for (const [key, template] of configEntries) { + if (d[key]) { + ctx.log.info(`${key} already exists, skipping`); + } else { + d[key] = JSON.parse(JSON.stringify(template)); + configChanged = true; + } + } + + // Step 3: Append "event" to relatedEntities.additional in Note and Todo + for (const entityKey of ['entity:Note', 'entity:Todo']) { + if (appendEventToRelatedEntities(d, entityKey, warnings)) { + configChanged = true; + } + } + + // Step 3b: Rename entity:Note relatedEntities label + const noteRelated = d['entity:Note']?.attributes?.relatedEntities; + if (noteRelated && noteRelated.label !== 'Sonstige verknüpfte Datensätze') { + noteRelated.label = 'Sonstige verknüpfte Datensätze'; + configChanged = true; + } + + // Step 4: Append event entry to navigation menu + if (appendEventToNavMenu(d, warnings)) { + configChanged = true; + } + + // Step 5: Add Veranstaltungen component to mentor/mentee detail views + for (const viewKey of ['view:mentor/:id', 'view:mentee/:id']) { + if (addVeranstaltungenToDetailView(d, viewKey, warnings)) { + configChanged = true; + } + } + + // Write config document if changed + if (configChanged) { + ctx.validateJson(config); + await ctx.put(CONFIG_DOC_PATH, config); + anyChange = true; + } + + if (!anyChange) { + ctx.log.info('No changes needed'); + } + + return { + changed: anyChange, + status: anyChange ? (ctx.dryRun ? 'dry-run' : 'ok') : 'no-change', + warnings: warnings.length > 0 ? warnings : undefined, + }; + }, +}; diff --git a/src/migration/migration-definition.ts b/src/migration/migration-definition.ts index 7eb6183..f8d70c1 100644 --- a/src/migration/migration-definition.ts +++ b/src/migration/migration-definition.ts @@ -37,6 +37,12 @@ export interface MigrationContext { * Run this before constructing any write payload. */ validateJson(value: unknown): void; + /** + * Write a document only if it does not already exist. + * Returns true when the document was created, false when it was already present. + * Throws on unexpected errors (anything other than 404). + */ + addDocIfMissing(path: string, template: unknown): Promise; } /** diff --git a/src/migration/migration-output.ts b/src/migration/migration-output.ts index f6db78f..3a96990 100644 --- a/src/migration/migration-output.ts +++ b/src/migration/migration-output.ts @@ -21,6 +21,7 @@ export function printOutcomes( showWriteStats: boolean, verbose = false, ): void { + console.log('\n── Summary: Status per org ──'); for (const { org, result: { result, writeStats }, diff --git a/src/migration/migrations.ts b/src/migration/migrations.ts index 3fc95aa..804ce73 100644 --- a/src/migration/migrations.ts +++ b/src/migration/migrations.ts @@ -1,3 +1,4 @@ +import { codoAddEventType } from './config-migration/codo-add-event-type.migration'; import { latestConfigFormats } from './config-migration/latest-config-formats.migration'; import { MigrationDefinition } from './migration-definition'; @@ -8,4 +9,7 @@ export const CONFIG_DOC_PATH = '/app/Config:CONFIG_ENTITY'; * Add new MigrationDefinitions here to make them available to the CLI. * One-off migrations follow the naming convention: oneoff-YYYYMMDD- */ -export const migrations: MigrationDefinition[] = [latestConfigFormats]; +export const migrations: MigrationDefinition[] = [ + latestConfigFormats, + codoAddEventType, +]; diff --git a/src/migration/testing/migration-idempotency.harness.ts b/src/migration/testing/migration-idempotency.harness.ts index 8382394..21e6fda 100644 --- a/src/migration/testing/migration-idempotency.harness.ts +++ b/src/migration/testing/migration-idempotency.harness.ts @@ -106,7 +106,7 @@ export function buildStubCouchdb(store: DocStore): Couchdb { } as unknown as Couchdb; } -const silentLogger: MigrationLogger = { +export const silentLogger: MigrationLogger = { info: () => {}, warn: () => {}, error: () => {}, @@ -120,9 +120,9 @@ const fakeOrg: SystemCredentials = { name: 'test', }; -function buildInMemoryContext( +export function buildTestContext( store: DocStore, - dryRun: boolean, + dryRun = false, ): MigrationContext & { store: DocStore } { const stubCouchdb = buildStubCouchdb(store); @@ -154,6 +154,18 @@ function buildInMemoryContext( await stubCouchdb.put(path, data, db); writes.succeeded++; }, + async addDocIfMissing(path, template) { + try { + await stubCouchdb.get(path); + return false; + } catch (error: unknown) { + if ((error as { status?: number }).status !== 404) { + throw error; + } + } + await this.put(path, template); + return true; + }, store, }; } @@ -172,13 +184,13 @@ export async function runIdempotencyCheck( ): Promise { // First run const store1: DocStore = JSON.parse(JSON.stringify(initialDocs)); - const ctx1 = buildInMemoryContext(store1, false); + const ctx1 = buildTestContext(store1, false); const firstRunResult = await migration.run(ctx1); const stateAfterFirstRun: DocStore = JSON.parse(JSON.stringify(store1)); // Second run — same store state as left after first run const store2: DocStore = JSON.parse(JSON.stringify(stateAfterFirstRun)); - const ctx2 = buildInMemoryContext(store2, false); + const ctx2 = buildTestContext(store2, false); const secondRunResult = await migration.run(ctx2); const stateAfterSecondRun: DocStore = JSON.parse(JSON.stringify(store2)); diff --git a/src/migration/tracked-migration-context.ts b/src/migration/tracked-migration-context.ts index d84700a..f41c46a 100644 --- a/src/migration/tracked-migration-context.ts +++ b/src/migration/tracked-migration-context.ts @@ -41,6 +41,20 @@ export class TrackedMigrationContext implements MigrationContext { }; } + async addDocIfMissing(path: string, template: unknown): Promise { + try { + await this.couchdb.get(path); + this.log.info(`${path} already exists, skipping`); + return false; + } catch (error: unknown) { + if (!is404Error(error)) { + throw error; + } + } + await this.put(path, template); + return true; + } + async put( path: string, data: unknown, @@ -121,3 +135,9 @@ function truncate(str: string | undefined, max = 200): string { if (!str) return '(undefined)'; return str.length > max ? str.slice(0, max) + '…' : str; } + +export function is404Error(error: unknown): boolean { + return ( + (error as { response?: { status?: number } }).response?.status === 404 + ); +} diff --git a/tsconfig.json b/tsconfig.json index 7a64609..fe8f31a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,7 @@ "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["jest", "node"] } }