diff --git a/apps/opencase/src/application/case/endpoints/CreateFramework.ts b/apps/opencase/src/application/case/endpoints/CreateFramework.ts index d042f77..2a78526 100644 --- a/apps/opencase/src/application/case/endpoints/CreateFramework.ts +++ b/apps/opencase/src/application/case/endpoints/CreateFramework.ts @@ -160,7 +160,7 @@ export class CreateFramework { // compare like-for-like against the existing persisted entities' own // .toJSON() output — comparing the raw POST payload directly against a // .toJSON()'d bundle would false-positive on every save, since GET-only - // fields (CFDocumentURI, CFPackageURI) are legitimately absent from POST + // fields (CFDocumentURI) are legitimately absent from POST // payloads by CASE spec convention but always present after fromRaw/toJSON. const buildEntities = (docPayload: any, itemsPayload: any[], assocPayload: any[], rubricsPayload: any[], uriOptions?: { preserveUris: boolean }) => { const doc = CFDocument.fromRaw(tenantId, caseVersion, docPayload, uriOptions) @@ -321,7 +321,7 @@ export class CreateFramework { CFItems: sortById(items.map(i => i.toJSON())), CFAssociations: sortById(associations.map(a => a.toJSON())), CFRubrics: sortById(rubrics.map(r => r.toJSON())), - CFDefinitions: definitions + CFDefinitions: pkg.definitions } if (stableStringify(existingBundle) === stableStringify(newBundle)) { diff --git a/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts b/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts index 0304623..0708097 100644 --- a/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts +++ b/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts @@ -1,7 +1,7 @@ import { type CaseVersion, type TenantId } from '../../../domain/case/value-objects/Identifiers' import { logger } from '../../../infrastructure/logging/Logger' import { type FileFrameworkStore } from '../../../infrastructure/persistence/file/FileFrameworkStore' -import { type LinkData } from '../../../domain/case/value-objects/LinkData' +import { buildCFPackageURI } from '../services/packageUri' export interface GetAllCFDocumentsQuery { tenantId?: TenantId // Optional — when omitted, lists across ALL tenants (global catalog) @@ -103,18 +103,12 @@ export class GetAllCFDocuments { const isV1p0 = query.caseVersion === '1.0' const cfDocuments = paginatedDocs.map(({ meta: docMeta, caseVersion }) => { - const packageURI: LinkData = { - title: 'CFPackage', - identifier: docMeta.sourcedId, - uri: `${requestedBasePath}/CFPackages/${docMeta.sourcedId}` - } - const doc: any = { identifier: docMeta.sourcedId, uri: `${requestedBasePath}/CFDocuments/${docMeta.sourcedId}`, title: docMeta.title, lastChangeDateTime: docMeta.lastChangeDateTime.toISOString(), - CFPackageURI: packageURI + CFPackageURI: buildCFPackageURI(docMeta.sourcedId, query.caseVersion, docMeta.openCaseExtensions) } // Add optional fields if present diff --git a/apps/opencase/src/application/case/endpoints/GetCFDocument.ts b/apps/opencase/src/application/case/endpoints/GetCFDocument.ts index 021d3fd..704e9e3 100644 --- a/apps/opencase/src/application/case/endpoints/GetCFDocument.ts +++ b/apps/opencase/src/application/case/endpoints/GetCFDocument.ts @@ -1,8 +1,8 @@ import { type CFPackageRepository } from '../ports/CFPackageRepository' import { type CaseVersion, type SourcedId, type TenantId } from '../../../domain/case/value-objects/Identifiers' import { logger } from '../../../infrastructure/logging/Logger' -import { type LinkData } from '../../../domain/case/value-objects/LinkData' import { type FileFrameworkStore } from '../../../infrastructure/persistence/file/FileFrameworkStore' +import { buildCFPackageURI, getOpenCaseExtension } from '../services/packageUri' export interface GetCFDocumentQuery { tenantId: TenantId @@ -30,21 +30,14 @@ export class GetCFDocument { // Note: Get by ID returns archived documents regardless - filtering only applies to list endpoints - // Generate CFPackageURI — uses caseVersion for URI generation - const basePath = query.caseVersion === '1.1' ? '/ims/case/v1p1' : '/ims/case/v1p0' - const packageURI: LinkData = { - title: 'CFPackage', - identifier: query.sourcedId, - uri: `${basePath}/CFPackages/${query.sourcedId}` - } - // Pass caseVersion to toJSON for correct field stripping when downconverting const serializeAs = query.loadVersion ? query.caseVersion : undefined const documentJSON = pkg.document.toJSON(serializeAs) - // Add CFPackageURI if not already present - if (!documentJSON.CFPackageURI) { - documentJSON.CFPackageURI = packageURI - } + documentJSON.CFPackageURI = buildCFPackageURI( + query.sourcedId, + query.caseVersion, + getOpenCaseExtension(pkg.document.extensions) + ) return documentJSON } diff --git a/apps/opencase/src/application/case/endpoints/GetCFPackage.ts b/apps/opencase/src/application/case/endpoints/GetCFPackage.ts index b89ffaf..7790f99 100644 --- a/apps/opencase/src/application/case/endpoints/GetCFPackage.ts +++ b/apps/opencase/src/application/case/endpoints/GetCFPackage.ts @@ -1,7 +1,6 @@ import type { CFPackageRepository } from '../ports/CFPackageRepository' import type { CaseVersion, SourcedId, TenantId } from '../../../domain/case/value-objects/Identifiers' import { logger } from '../../../infrastructure/logging/Logger' -import { LinkData } from '../../../domain/case/value-objects/LinkData' import type { FileFrameworkStore } from '../../../infrastructure/persistence/file/FileFrameworkStore' export interface GetCFPackageQuery { @@ -29,25 +28,22 @@ export class GetCFPackage { // Note: Get by ID returns archived packages regardless - filtering only applies to list endpoints - // Generate CFPackageURI for the document — uses caseVersion for URI generation - const basePath = query.caseVersion === '1.1' ? '/ims/case/v1p1' : '/ims/case/v1p0' - const packageURI: LinkData = { - title: 'CFPackage', - identifier: query.docId, - uri: `${basePath}/CFPackages/${query.docId}` - } - // Pass caseVersion to toJSON for correct field stripping (e.g. v1.0 omits v1.1-only fields) const serializeAs = query.loadVersion ? query.caseVersion : undefined + // No CFPackageURI here: a CFPackage's CFDocument is a CFPckgDocument, which + // doesn't carry one (only getCFDocument(s) responses do). const documentJSON = pkg.document.toJSON(serializeAs) - // Add CFPackageURI if not already present - if (!documentJSON.CFPackageURI) { - documentJSON.CFPackageURI = packageURI + + // Likewise CFPckgItem has no CFDocumentURI — the package itself establishes + // the document; only getCFItem responses carry the back-reference. + const toPckgItem = (item: typeof pkg.items[number]) => { + const { CFDocumentURI: _documentUri, ...rest } = item.toJSON(serializeAs) + return rest } const result: any = { CFDocument: documentJSON, - CFItems: pkg.items.map(i => i.toJSON(serializeAs)), + CFItems: pkg.items.map(toPckgItem), CFAssociations: pkg.associations.map(a => a.toJSON(serializeAs)) } diff --git a/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts index 4bc37c4..cc1f4a1 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts @@ -295,6 +295,23 @@ describe('GetAllCFDocuments', () => { expect(result.CFDocuments[0].extensions).toEqual({ 'ext:opencase': openCaseExtensions }) }) + it('should derive CFPackageURI from mirror/fork state (CGE-59)', async () => { + const source = 'https://standards.example.org/ims/case/v1p1/CFPackages/mirror' + const documents = [ + { sourcedId: 'mirror', title: 'Mirror', lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), currentFile: 'a.json', + openCaseExtensions: { isModifiedFromSource: false, sourcePackageURI: source } }, + { sourcedId: 'fork', title: 'Fork', lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), currentFile: 'b.json', + openCaseExtensions: { isModifiedFromSource: true, sourcePackageURI: source } } + ] + mockStore.getAllDocuments.mockImplementation((_: any, v: any) => (v === '1.1' ? documents as any : [])) + + const result = await getAllCFDocuments.execute({ tenantId, caseVersion }) + const byId = Object.fromEntries(result.CFDocuments.map((d: any) => [d.identifier, d])) + + expect(byId.mirror.CFPackageURI.uri).toBe(source) + expect(byId.fork.CFPackageURI.uri).toBe('/ims/case/v1p1/CFPackages/fork') + }) + it('should omit the extensions block entirely for CASE 1.0', async () => { const documents = [ { diff --git a/apps/opencase/src/application/case/endpoints/__tests__/GetCFDocument.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/GetCFDocument.test.ts index 877c22c..f5fcb5d 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/GetCFDocument.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/GetCFDocument.test.ts @@ -68,6 +68,42 @@ describe('GetCFDocument', () => { })) }) + it('should point a pristine mirror\'s CFPackageURI at its import source, ignoring any stored value (CGE-59)', async () => { + const source = 'https://standards.example.org/ims/case/v1p1/CFPackages/doc-123' + const document = CFDocument.fromRaw(tenantId, caseVersion, { + identifier: docId, + uri: 'https://standards.example.org/doc-123', + creator: 'Source', + title: 'Mirrored', + lastChangeDateTime: '2024-01-01T00:00:00Z', + CFPackageURI: { title: 'Stale', identifier: 'other', uri: 'https://elsewhere.example.org/x' }, + extensions: { 'ext:opencase': { isModifiedFromSource: false, sourcePackageURI: source } } + }, { preserveUris: true }) + mockRepository.load.mockResolvedValue(new CFPackage({ document, items: [], associations: [] })) + + const result = await getCFDocument.execute({ tenantId, caseVersion, sourcedId: docId }) + + expect(result.CFPackageURI).toEqual({ title: 'CFPackage', identifier: docId, uri: source }) + }) + + it('should point a forked framework\'s CFPackageURI at this server (CGE-59)', async () => { + const document = CFDocument.create({ + tenantId, + caseVersion, + sourcedId: docId, + uri: `/ims/case/v1p1/CFDocuments/${docId}`, + creator: 'Test Creator', + title: 'Forked', + lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), + extensions: { 'ext:opencase': { isModifiedFromSource: true, sourcePackageURI: 'https://standards.example.org/x' } } + }) + mockRepository.load.mockResolvedValue(new CFPackage({ document, items: [], associations: [] })) + + const result = await getCFDocument.execute({ tenantId, caseVersion, sourcedId: docId }) + + expect(result.CFPackageURI.uri).toBe(`/ims/case/v1p1/CFPackages/${docId}`) + }) + it('should include caseVersion for a CASE 1.1 document', async () => { const document = CFDocument.create({ tenantId, diff --git a/apps/opencase/src/application/case/endpoints/__tests__/GetCFPackage.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/GetCFPackage.test.ts index def0718..f80ed23 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/GetCFPackage.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/GetCFPackage.test.ts @@ -128,10 +128,7 @@ describe('GetCFPackage', () => { expect(result).toEqual({ CFDocument: expect.objectContaining({ identifier: docId, - title: 'Test Document', - CFPackageURI: expect.objectContaining({ - identifier: docId - }) + title: 'Test Document' }), CFItems: expect.arrayContaining([ expect.objectContaining({ identifier: 'item-1' }), @@ -149,6 +146,11 @@ describe('GetCFPackage', () => { }) ]) }); + // CFPckgDocument has no CFPackageURI; CFPckgItem has no CFDocumentURI (CGE-59) + expect(result!.CFDocument).not.toHaveProperty('CFPackageURI'); + for (const item of result!.CFItems) { + expect(item).not.toHaveProperty('CFDocumentURI'); + } }); it('should handle empty items and associations', async () => { @@ -176,14 +178,13 @@ describe('GetCFPackage', () => { expect(result).toEqual({ CFDocument: expect.objectContaining({ identifier: docId, - title: 'Test Document', - CFPackageURI: expect.objectContaining({ - identifier: docId - }) + title: 'Test Document' }), CFItems: [], CFAssociations: [] }); + // CFPckgDocument has no CFPackageURI (CGE-59) + expect(result!.CFDocument).not.toHaveProperty('CFPackageURI'); }); it('should return archived document when getting by ID (archived documents always returned)', async () => { diff --git a/apps/opencase/src/application/case/endpoints/__tests__/ImportFramework.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/ImportFramework.test.ts index cb8acc5..864d1c1 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/ImportFramework.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/ImportFramework.test.ts @@ -154,6 +154,51 @@ describe('ImportFramework', () => { expect(savedPkg.document.toJSON().uri).toBe('https://source.example.org/ims/case/v1p1/CFDocuments/doc-123') }) + it('accepts a package with extra/unknown fields and does not save them (CGE-59)', async () => { + await importFramework.execute({ + tenantId, + caseVersion, + cfPackage: { + CFPackage: { + CFDocument: { + ...cfDocument, + CFPackageURI: { title: 'Pkg', identifier: 'doc-123', uri: 'https://source.example.org/ims/case/v1p1/CFPackages/doc-123' }, + somethingBogus: 'x' + }, + CFItems: [{ + identifier: 'item-1', + uri: '/ims/case/v1p1/CFItems/item-1', + fullStatement: 'Item', + lastChangeDateTime: '2024-01-01T00:00:00Z', + notInSpec: 42 + }], + CFAssociations: [], + CFDefinitions: { + CFSubjects: [{ identifier: 's-1', uri: 'u', title: 'Math', hierarchyCode: '1', lastChangeDateTime: 'd', vendorJunk: true }], + CFMadeUp: [] + }, + totallyUnknownRoot: { a: 1 } + } + } + }) + + const savedPkg = mockRepository.saveNewVersion.mock.calls[0][2] + const doc = savedPkg.document.toJSON() + expect(doc).not.toHaveProperty('CFPackageURI') + expect(doc).not.toHaveProperty('somethingBogus') + expect(savedPkg.items[0].toJSON()).not.toHaveProperty('notInSpec') + expect(savedPkg.definitions).toEqual({ + CFSubjects: [{ identifier: 's-1', uri: 'u', title: 'Math', hierarchyCode: '1', lastChangeDateTime: 'd' }] + }) + }) + + it('does not add a CFPackageURI when the source has none (CGE-59)', async () => { + await importFramework.execute({ tenantId, caseVersion, cfPackage: { CFDocument: cfDocument } }) + + const savedPkg = mockRepository.saveNewVersion.mock.calls[0][2] + expect(savedPkg.document.toJSON()).not.toHaveProperty('CFPackageURI') + }) + it('throws when neither endpointUrl nor cfPackage is provided', async () => { await expect( importFramework.execute({ tenantId, caseVersion }) diff --git a/apps/opencase/src/application/case/services/__tests__/packageUri.test.ts b/apps/opencase/src/application/case/services/__tests__/packageUri.test.ts new file mode 100644 index 0000000..934aa08 --- /dev/null +++ b/apps/opencase/src/application/case/services/__tests__/packageUri.test.ts @@ -0,0 +1,35 @@ +import { buildCFPackageURI, getOpenCaseExtension } from '../packageUri' + +describe('buildCFPackageURI (CGE-59)', () => { + const source = 'https://standards.example.org/ims/case/v1p1/CFPackages/doc-1' + + it('points a pristine mirror imported by URL at its source', () => { + expect(buildCFPackageURI('doc-1', '1.1', { isModifiedFromSource: false, sourcePackageURI: source })) + .toEqual({ title: 'CFPackage', identifier: 'doc-1', uri: source }) + }) + + it('points a forked framework at this server', () => { + expect(buildCFPackageURI('doc-2', '1.1', { isModifiedFromSource: true, sourcePackageURI: source }).uri) + .toBe('/ims/case/v1p1/CFPackages/doc-2') + }) + + it('points a mirror pasted as JSON (no known source) at this server', () => { + expect(buildCFPackageURI('doc-1', '1.1', { isModifiedFromSource: false }).uri) + .toBe('/ims/case/v1p1/CFPackages/doc-1') + }) + + it('points a locally authored framework at this server, using the requested CASE version', () => { + expect(buildCFPackageURI('doc-1', '1.0', undefined).uri).toBe('/ims/case/v1p0/CFPackages/doc-1') + }) +}) + +describe('getOpenCaseExtension', () => { + it('returns the ext:opencase object when present', () => { + expect(getOpenCaseExtension({ 'ext:opencase': { a: 1 } })).toEqual({ a: 1 }) + }) + + it('returns undefined when missing or not an object', () => { + expect(getOpenCaseExtension(undefined)).toBeUndefined() + expect(getOpenCaseExtension({ 'ext:opencase': 'x' })).toBeUndefined() + }) +}) diff --git a/apps/opencase/src/application/case/services/packageUri.ts b/apps/opencase/src/application/case/services/packageUri.ts new file mode 100644 index 0000000..1baf9cf --- /dev/null +++ b/apps/opencase/src/application/case/services/packageUri.ts @@ -0,0 +1,30 @@ +import type { CaseVersion, SourcedId } from '../../../domain/case/value-objects/Identifiers' +import type { LinkData } from '../../../domain/case/value-objects/LinkData' + +/** + * Derive the CFPackageURI returned on a CFDocument by getCFDocument/getCFDocuments. + * It is never stored — a CFPackage's CFDocument (CFPckgDocument) has no + * CFPackageURI, so it only exists in these responses. + * + * - Pristine mirror imported by URL: points at the source it was imported from. + * - Everything else (forks, locally authored, mirrors pasted as JSON with no + * known source): points at this server's CFPackages endpoint. + */ +export function buildCFPackageURI ( + docId: SourcedId, + caseVersion: CaseVersion, + openCaseExtensions: Record | undefined +): LinkData { + const sourcePackageURI = openCaseExtensions?.sourcePackageURI + const isPristineMirror = openCaseExtensions?.isModifiedFromSource === false + const uri = isPristineMirror && typeof sourcePackageURI === 'string' && sourcePackageURI.trim() !== '' + ? sourcePackageURI.trim() + : `${caseVersion === '1.1' ? '/ims/case/v1p1' : '/ims/case/v1p0'}/CFPackages/${docId}` + return { title: 'CFPackage', identifier: docId, uri } +} + +/** Read the `ext:opencase` object off a CFDocument's extensions, if present. */ +export function getOpenCaseExtension (extensions: Record | undefined): Record | undefined { + const ext = extensions?.['ext:opencase'] + return ext && typeof ext === 'object' ? ext as Record : undefined +} diff --git a/apps/opencase/src/domain/case/entities/CFDocument.ts b/apps/opencase/src/domain/case/entities/CFDocument.ts index e7ca5b9..bbdc1b9 100644 --- a/apps/opencase/src/domain/case/entities/CFDocument.ts +++ b/apps/opencase/src/domain/case/entities/CFDocument.ts @@ -23,7 +23,6 @@ export interface CFDocumentProps { notes?: string; statusStartDate?: string; statusEndDate?: string; - CFPackageURI?: LinkData; extensions?: Record; } @@ -62,11 +61,13 @@ export class CFDocument { } // Rebase reference URIs onto the local host — these point at per-tenant - // definition entities (licenses, packages, subjects) that OpenCASE serves + // definition entities (licenses, subjects) that OpenCASE serves // itself, so they must resolve locally rather than to the source host. // Mirrored frameworks skip this and keep the source's reference URIs as-is. const licenseURI = options?.preserveUris ? raw.licenseURI : LinkDataHelper.rebaseLinkData(raw.licenseURI, caseVersion, 'CFLicenses') - const CFPackageURI = options?.preserveUris ? raw.CFPackageURI : LinkDataHelper.rebaseLinkData(raw.CFPackageURI, caseVersion, 'CFPackages') + // CFPackageURI is intentionally not read: it isn't part of a CFPackage's + // CFDocument (CFPckgDocument), so an incoming one is ignored rather than + // stored. getCFDocument(s) derive it at response time (see packageUri.ts). // subjectURI must use LinkURI format (UUID identifier required) const subjectURI = options?.preserveUris ? raw.subjectURI @@ -102,7 +103,6 @@ export class CFDocument { notes: raw.notes, statusStartDate: raw.statusStartDate, statusEndDate: raw.statusEndDate, - CFPackageURI, extensions: raw.extensions }); } @@ -116,6 +116,7 @@ export class CFDocument { get tenantId(): TenantId { return this.props.tenantId; } get caseVersion(): CaseVersion { return this.props.caseVersion; } get sourcedId(): SourcedId { return this.props.sourcedId; } + get extensions(): Record | undefined { return this.props.extensions; } toJSON(serializeAs?: CaseVersion) { const { tenantId, caseVersion, sourcedId, licenceUri, ...rest } = this.props; diff --git a/apps/opencase/src/domain/case/entities/CFPackage.ts b/apps/opencase/src/domain/case/entities/CFPackage.ts index 3ea5db4..1a01039 100644 --- a/apps/opencase/src/domain/case/entities/CFPackage.ts +++ b/apps/opencase/src/domain/case/entities/CFPackage.ts @@ -12,6 +12,46 @@ export interface CFDefinitions { extensions?: Record } +/** + * Fields each CFDefinitions entry may carry, per the CASE v1.1 CFPackage schema + * (CFConcept, CFSubject, CFLicense, CFItemType, CFAssociationGrouping). + * Anything else on an incoming entry is dropped rather than persisted. + */ +const DEFINITION_FIELDS: Record = { + CFConcepts: ['identifier', 'uri', 'title', 'keywords', 'hierarchyCode', 'description', 'lastChangeDateTime', 'extensions'], + CFSubjects: ['identifier', 'uri', 'title', 'hierarchyCode', 'description', 'lastChangeDateTime', 'extensions'], + CFLicenses: ['identifier', 'uri', 'title', 'description', 'licenseText', 'lastChangeDateTime', 'extensions'], + CFItemTypes: ['identifier', 'uri', 'title', 'description', 'hierarchyCode', 'typeCode', 'lastChangeDateTime', 'extensions'], + CFAssociationGroupings: ['identifier', 'uri', 'title', 'description', 'lastChangeDateTime', 'extensions'] +} + +function pickFields (entry: any, fields: readonly string[]): Record { + const out: Record = {} + for (const f of fields) { + if (entry[f] !== undefined) out[f] = entry[f] + } + return out +} + +/** + * Keep only the spec-defined categories and entry fields of a CFDefinitions + * block — the same whitelisting CFDocument/CFItem/CFAssociation.fromRaw apply + * to their own fields — so unknown data from an import is ignored, not saved. + */ +export function sanitizeCFDefinitions (raw: any): CFDefinitions | null { + if (!raw || typeof raw !== 'object') return null + const result: CFDefinitions = {} + for (const [category, fields] of Object.entries(DEFINITION_FIELDS)) { + const entries = raw[category] + if (!Array.isArray(entries)) continue + (result as any)[category] = entries + .filter((e: unknown) => e && typeof e === 'object') + .map((e: any) => pickFields(e, fields)) + } + if (raw.extensions && typeof raw.extensions === 'object') result.extensions = raw.extensions + return result +} + export interface CFPackageProps { document: CFDocument items: CFItem[] @@ -22,7 +62,11 @@ export interface CFPackageProps { } export class CFPackage { - constructor (private readonly props: CFPackageProps) {} + private readonly props: CFPackageProps + + constructor (props: CFPackageProps) { + this.props = { ...props, definitions: sanitizeCFDefinitions(props.definitions) } + } get document (): CFDocument { return this.props.document } get items (): CFItem[] { return this.props.items } diff --git a/apps/opencase/src/domain/case/entities/__tests__/CFDocument.test.ts b/apps/opencase/src/domain/case/entities/__tests__/CFDocument.test.ts index ccb49cd..3e16601 100644 --- a/apps/opencase/src/domain/case/entities/__tests__/CFDocument.test.ts +++ b/apps/opencase/src/domain/case/entities/__tests__/CFDocument.test.ts @@ -109,7 +109,7 @@ describe('CFDocument', () => { expect(json.lastChangeDateTime).toBe('2024-01-01T12:30:45.000Z'); }); - it('should rewrite licenseURI/CFPackageURI/subjectURI to the local host on import (POR-730)', () => { + it('should rewrite licenseURI/subjectURI to the local host and drop CFPackageURI on import (POR-730, CGE-59)', () => { const raw = { sourcedId: 'doc-123', title: 'Test Document', @@ -135,9 +135,29 @@ describe('CFDocument', () => { const json = doc.toJSON(); expect(json.licenseURI.uri).toBe('/ims/case/v1p1/CFLicenses/c0c0c0c0-0000-4000-a000-000000000002'); - expect(json.CFPackageURI.uri).toBe('/ims/case/v1p1/CFPackages/doc-123'); + expect(json.CFPackageURI).toBeUndefined(); expect(json.subjectURI[0].uri).toBe('/ims/case/v1p1/CFSubjects/c0c0c0c0-0000-4000-a000-000000000012'); }); + + it('should ignore an incoming CFPackageURI on a mirrored import (CGE-59)', () => { + const raw = { + identifier: 'doc-123', + uri: 'https://standards.example.org/doc-123', + title: 'Test Document', + creator: 'Source', + lastChangeDateTime: '2024-01-01T00:00:00Z', + CFPackageURI: { + title: 'Package', + identifier: 'doc-123', + uri: 'https://standards.example.org/ims/case/v1p1/CFPackages/doc-123' + } + }; + + const json = CFDocument.fromRaw(tenantId, caseVersion, raw, { preserveUris: true }).toJSON(); + + expect(json.CFPackageURI).toBeUndefined(); + expect(json.uri).toBe('https://standards.example.org/doc-123'); + }); }); describe('toJSON', () => { diff --git a/apps/opencase/src/domain/case/entities/__tests__/CFPackage.test.ts b/apps/opencase/src/domain/case/entities/__tests__/CFPackage.test.ts index 16a99bc..f773fbe 100644 --- a/apps/opencase/src/domain/case/entities/__tests__/CFPackage.test.ts +++ b/apps/opencase/src/domain/case/entities/__tests__/CFPackage.test.ts @@ -102,5 +102,49 @@ describe('CFPackage', () => { expect(pkg.rubrics.length).toBe(1); expect(pkg.rubrics[0].identifier).toBe('rubric-1'); }); -}); + describe('definitions (CGE-59)', () => { + it('drops unknown categories and unknown fields on definition entries, keeping extensions', () => { + const pkg = new CFPackage({ + document: createDocument(), + items: [], + associations: [], + definitions: { + CFItemTypes: [{ + identifier: 'type-1', + uri: '/ims/case/v1p1/CFItemTypes/type-1', + title: 'Standard', + description: 'A standard', + hierarchyCode: '1', + typeCode: 'std', + lastChangeDateTime: '2024-01-01T00:00:00Z', + extensions: { 'ext:vendor': { keep: true } }, + bogusField: 'drop me' + }], + CFLicenses: [{ identifier: 'lic-1', uri: 'u', title: 'L', licenseText: 'T', lastChangeDateTime: 'd', junk: 1 }], + CFNotARealCategory: [{ identifier: 'x' }], + extensions: { 'ext:vendor': {} } + } as any + }); + + expect(pkg.definitions).toEqual({ + CFItemTypes: [{ + identifier: 'type-1', + uri: '/ims/case/v1p1/CFItemTypes/type-1', + title: 'Standard', + description: 'A standard', + hierarchyCode: '1', + typeCode: 'std', + lastChangeDateTime: '2024-01-01T00:00:00Z', + extensions: { 'ext:vendor': { keep: true } } + }], + CFLicenses: [{ identifier: 'lic-1', uri: 'u', title: 'L', licenseText: 'T', lastChangeDateTime: 'd' }], + extensions: { 'ext:vendor': {} } + }); + }); + + it('returns null when no definitions are given', () => { + expect(new CFPackage({ document: createDocument(), items: [], associations: [] }).definitions).toBeNull(); + }); + }); +}); diff --git a/apps/opencase/src/infrastructure/http/OpenAPISpecGenerator.ts b/apps/opencase/src/infrastructure/http/OpenAPISpecGenerator.ts index e7d775b..63f535a 100644 --- a/apps/opencase/src/infrastructure/http/OpenAPISpecGenerator.ts +++ b/apps/opencase/src/infrastructure/http/OpenAPISpecGenerator.ts @@ -1626,12 +1626,12 @@ export class OpenAPISpecGenerator { required: ['CFDocument', 'CFItems', 'CFAssociations'], properties: { CFDocument: { - $ref: '#/components/schemas/CFDocumentDType' + $ref: '#/components/schemas/CFPckgDocumentDType' }, CFItems: { type: 'array', items: { - $ref: '#/components/schemas/CFItemDType' + $ref: '#/components/schemas/CFPckgItemDType' } }, CFAssociations: { @@ -2086,6 +2086,22 @@ export class OpenAPISpecGenerator { spec.paths['/management/tenants/{tenantId}/ims/case/v1p0/CFPackages/{id}'] = deleteByVersion('1.0') spec.paths['/management/tenants/{tenantId}/ims/case/v1p1/CFPackages/{id}'] = deleteByVersion('1.1') + // A CFPackage's CFDocument is a CFPckgDocument: the same as CFDocument but + // without CFPackageURI, which only appears in getCFDocument(s) responses. + const { CFPackageURI: _packageUri, ...pckgDocumentProperties } = spec.components.schemas.CFDocumentDType.properties + spec.components.schemas.CFPckgDocumentDType = { + ...spec.components.schemas.CFDocumentDType, + properties: pckgDocumentProperties + } + + // Likewise a CFPackage's items are CFPckgItems, which have no CFDocumentURI. + const { CFDocumentURI: _documentUri, ...pckgItemProperties } = spec.components.schemas.CFItemDType.properties + spec.components.schemas.CFPckgItemDType = { + ...spec.components.schemas.CFItemDType, + required: spec.components.schemas.CFItemDType.required.filter((r: string) => r !== 'CFDocumentURI'), + properties: pckgItemProperties + } + return spec } diff --git a/apps/opencase/src/infrastructure/http/__tests__/OpenAPISpecGenerator.test.ts b/apps/opencase/src/infrastructure/http/__tests__/OpenAPISpecGenerator.test.ts index 5c24a69..b79fa54 100644 --- a/apps/opencase/src/infrastructure/http/__tests__/OpenAPISpecGenerator.test.ts +++ b/apps/opencase/src/infrastructure/http/__tests__/OpenAPISpecGenerator.test.ts @@ -29,5 +29,20 @@ describe('OpenAPISpecGenerator (discovery)', () => { expect(spec.paths['/management/tenants/{tenantId}/ims/case/v1p0/CFPackages/{id}']).toBeDefined() expect(spec.paths['/management/tenants/{tenantId}/ims/case/v1p1/CFPackages/{id}']).toBeDefined() }) -}) + it('describes CFPackage contents as CFPckgDocument/CFPckgItem without CFPackageURI/CFDocumentURI (CGE-59)', () => { + const { schemas } = OpenAPISpecGenerator.generateV1p1({ baseUrl: 'https://example.com', version: '1.0.0' }).components + + expect(schemas.CFPackageDType.properties.CFDocument.$ref).toBe('#/components/schemas/CFPckgDocumentDType') + expect(schemas.CFPckgDocumentDType.properties.CFPackageURI).toBeUndefined() + expect(schemas.CFPckgDocumentDType.properties.title).toBeDefined() + // getCFDocument(s) still carry it + expect(schemas.CFDocumentDType.properties.CFPackageURI).toBeDefined() + + expect(schemas.CFPackageDType.properties.CFItems.items.$ref).toBe('#/components/schemas/CFPckgItemDType') + expect(schemas.CFPckgItemDType.properties.CFDocumentURI).toBeUndefined() + expect(schemas.CFPckgItemDType.required).not.toContain('CFDocumentURI') + // getCFItem still carries it + expect(schemas.CFItemDType.required).toContain('CFDocumentURI') + }) +})