From 94e9a8fbf2081a5552503e73b6e84a6a1c20b3d0 Mon Sep 17 00:00:00 2001 From: Stan Billiet <99833815+stanbilliet@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:42:15 +0200 Subject: [PATCH 1/2] Keep /CFDocuments/ URIs for association nodes that are CFDocuments (#44) CFAssociation.fromRaw regenerated originNodeURI.uri and destinationNodeURI.uri as /ims/case/v1p1/CFItems/{identifier} for every node, including nodes that are a CFDocument (the top-level isChildOf of every framework, and links to the CFDocument of another framework). Those URIs pointed at the CFItems endpoint for a document identifier and returned 404. A node is now regenerated as /CFDocuments/{identifier} when: - its identifier is the package's own CFDocument identifier, or - the source URI points at /CFDocuments/{identifier} (relative or absolute, any CASE version), or - the source URI is a urn:case:document: URN. All other nodes keep the existing /CFItems/{identifier} behaviour, and only the identifier and node kind are trusted from the source, so URIs are still rebased onto the local host. Because the stored URI keeps the /CFDocuments/ path, the result is stable when the package is reloaded. The package document identifier is passed from CreateFramework, ImportFramework, FileCFPackageRepository and UpdateCFAssociation. --- .../case/endpoints/CreateFramework.ts | 4 +- .../case/endpoints/ImportFramework.ts | 2 +- .../case/endpoints/UpdateCFAssociation.ts | 4 +- .../src/domain/case/entities/CFAssociation.ts | 108 ++++++++++++------ .../entities/__tests__/CFAssociation.test.ts | 98 ++++++++++++++++ .../file/FileCFPackageRepository.ts | 2 +- 6 files changed, 180 insertions(+), 38 deletions(-) diff --git a/apps/opencase/src/application/case/endpoints/CreateFramework.ts b/apps/opencase/src/application/case/endpoints/CreateFramework.ts index ce71448..cd30f99 100644 --- a/apps/opencase/src/application/case/endpoints/CreateFramework.ts +++ b/apps/opencase/src/application/case/endpoints/CreateFramework.ts @@ -169,7 +169,7 @@ export class CreateFramework { return { document: doc, items: itemsPayload.map((i: any) => CFItem.fromRaw(tenantId, caseVersion, i, dId, dURI, uriOptions)), - associations: assocPayload.map((a: any) => CFAssociation.fromRaw(tenantId, caseVersion, a, uriOptions)), + associations: assocPayload.map((a: any) => CFAssociation.fromRaw(tenantId, caseVersion, a, { ...uriOptions, documentIdentifiers: [dId] })), rubrics: rubricsPayload.map((r: any) => CFRubric.fromRaw(tenantId, caseVersion, r, uriOptions)) } } @@ -295,7 +295,7 @@ export class CreateFramework { CFItem.fromRaw(tenantId, caseVersion, i, docId, docURI, preserveUris) ) const associations = cfAssociationsPayload.map((a: any) => - CFAssociation.fromRaw(tenantId, caseVersion, a, preserveUris) + CFAssociation.fromRaw(tenantId, caseVersion, a, { ...preserveUris, documentIdentifiers: [docId] }) ) const rubrics = cfRubricsPayload.map((r: any) => CFRubric.fromRaw(tenantId, caseVersion, r, preserveUris) diff --git a/apps/opencase/src/application/case/endpoints/ImportFramework.ts b/apps/opencase/src/application/case/endpoints/ImportFramework.ts index 33fb320..4c3b638 100644 --- a/apps/opencase/src/application/case/endpoints/ImportFramework.ts +++ b/apps/opencase/src/application/case/endpoints/ImportFramework.ts @@ -160,7 +160,7 @@ export class ImportFramework { const docURI = docJSON.uri const items = (payload.CFItems ?? []).map(i => CFItem.fromRaw(tenantId, caseVersion, i, docId, docURI, preserveUris)) const associations = (payload.CFAssociations ?? []).map(a => - CFAssociation.fromRaw(tenantId, caseVersion, a, preserveUris) + CFAssociation.fromRaw(tenantId, caseVersion, a, { ...preserveUris, documentIdentifiers: [docId] }) ) const rubrics = (payload.CFRubrics ?? []).map(r => CFRubric.fromRaw(tenantId, caseVersion, r, preserveUris) diff --git a/apps/opencase/src/application/case/endpoints/UpdateCFAssociation.ts b/apps/opencase/src/application/case/endpoints/UpdateCFAssociation.ts index d5ab621..0a82903 100644 --- a/apps/opencase/src/application/case/endpoints/UpdateCFAssociation.ts +++ b/apps/opencase/src/application/case/endpoints/UpdateCFAssociation.ts @@ -54,7 +54,9 @@ export class UpdateCFAssociation { } // Update the specific association - const updatedAssociation = CFAssociation.fromRaw(tenantId, caseVersion, payload) + const updatedAssociation = CFAssociation.fromRaw(tenantId, caseVersion, payload, { + documentIdentifiers: [existingPkg.document.sourcedId] + }) // Replace the association in the associations array const associations = existingPkg.associations.map(a => diff --git a/apps/opencase/src/domain/case/entities/CFAssociation.ts b/apps/opencase/src/domain/case/entities/CFAssociation.ts index 1652148..0bee4e4 100644 --- a/apps/opencase/src/domain/case/entities/CFAssociation.ts +++ b/apps/opencase/src/domain/case/entities/CFAssociation.ts @@ -28,7 +28,17 @@ export class CFAssociation { return new CFAssociation(props); } - static fromRaw(tenantId: TenantId, caseVersion: CaseVersion, raw: any, options?: { preserveUris?: boolean }): CFAssociation { + /** + * @param options.preserveUris keep the source's identifiers and URIs exactly as supplied (mirrored frameworks) + * @param options.documentIdentifiers identifiers known to be CFDocuments (e.g. the package's own CFDocument); + * association nodes with one of these identifiers get a /CFDocuments/{id} URI + */ + static fromRaw( + tenantId: TenantId, + caseVersion: CaseVersion, + raw: any, + options?: { preserveUris?: boolean, documentIdentifiers?: Iterable } + ): CFAssociation { // Extract identifier from URN if present (priority over sourcedId/identifier) let identifier = raw.sourcedId || raw.identifier let uri = raw.uri @@ -56,38 +66,14 @@ export class CFAssociation { originNodeURI = raw.originNodeURI destinationNodeURI = raw.destinationNodeURI } else { - // originNodeURI/destinationNodeURI always reference a CFItem (or CFDocument) - // within the SAME package being imported, so — like CFItem.CFDocumentURI — - // their uri is always regenerated to point at the local host, regardless of - // what URI shape (URN, absolute foreign-host URL, or relative path) the - // source supplied. Only the identifier is trusted from the source data. - let originId = raw.originNodeURI?.identifier ?? raw.originNode ?? 'unknown' - const originUriFromSource = raw.originNodeURI?.uri - if (originUriFromSource && UrnCaseUriHelper.isUrnCaseUri(originUriFromSource)) { - const parsed = UrnCaseUriHelper.parseUrnCaseUri(originUriFromSource) - if (parsed) { - originId = parsed.identifier || originId - } - } - originNodeURI = { - title: raw.originNodeURI?.title ?? String(originId), - identifier: originId, - uri: this.generateItemURI(tenantId, caseVersion, originId) - } - - let destinationId = raw.destinationNodeURI?.identifier ?? raw.destinationNode ?? 'unknown' - const destinationUriFromSource = raw.destinationNodeURI?.uri - if (destinationUriFromSource && UrnCaseUriHelper.isUrnCaseUri(destinationUriFromSource)) { - const parsed = UrnCaseUriHelper.parseUrnCaseUri(destinationUriFromSource) - if (parsed) { - destinationId = parsed.identifier || destinationId - } - } - destinationNodeURI = { - title: raw.destinationNodeURI?.title ?? String(destinationId), - identifier: destinationId, - uri: this.generateItemURI(tenantId, caseVersion, destinationId) - } + // originNodeURI/destinationNodeURI reference a CFItem or a CFDocument, so — + // like CFItem.CFDocumentURI — their uri is always regenerated to point at the + // local host, regardless of what URI shape (URN, absolute foreign-host URL, or + // relative path) the source supplied. Only the identifier, and whether the node + // is a CFDocument or a CFItem, are trusted from the source data. + const documentIdentifiers = new Set(options?.documentIdentifiers ?? []) + originNodeURI = this.buildNodeLinkData(tenantId, caseVersion, raw.originNodeURI, raw.originNode, documentIdentifiers) + destinationNodeURI = this.buildNodeLinkData(tenantId, caseVersion, raw.destinationNodeURI, raw.destinationNode, documentIdentifiers) } // CFAssociationGroupingURI references a per-tenant definition entity that @@ -127,6 +113,62 @@ export class CFAssociation { return `${basePath}/CFItems/${identifier}`; } + private static generateDocumentURI(tenantId: TenantId, caseVersion: CaseVersion, identifier: string): string { + const basePath = caseVersion === '1.1' ? '/ims/case/v1p1' : '/ims/case/v1p0'; + return `${basePath}/CFDocuments/${identifier}`; + } + + /** + * Builds the local LinkData for an association node (origin or destination). + * The node is treated as a CFDocument when its identifier is a known document + * identifier, when the source supplied a `urn:case:document:` URN, or when the + * source URI (relative or absolute, any CASE version) points at + * `/CFDocuments/{identifier}`. Otherwise it is a CFItem. + */ + private static buildNodeLinkData( + tenantId: TenantId, + caseVersion: CaseVersion, + link: any, + legacyNodeId: unknown, + documentIdentifiers: Set + ): LinkData { + let id = link?.identifier ?? legacyNodeId ?? 'unknown' + const uriFromSource: unknown = link?.uri + let isDocument = false + + if (typeof uriFromSource === 'string' && UrnCaseUriHelper.isUrnCaseUri(uriFromSource)) { + const parsed = UrnCaseUriHelper.parseUrnCaseUri(uriFromSource) + if (parsed) { + id = parsed.identifier || id + isDocument = parsed.type === 'document' + } + } else if (typeof uriFromSource === 'string') { + isDocument = this.isDocumentUri(uriFromSource, String(id)) + } + + if (documentIdentifiers.has(String(id))) isDocument = true + + return { + title: link?.title ?? String(id), + identifier: id, + uri: isDocument + ? this.generateDocumentURI(tenantId, caseVersion, id) + : this.generateItemURI(tenantId, caseVersion, id) + } + } + + /** True when `uri` is a CASE CFDocuments URI for `identifier` (e.g. `…/ims/case/v1p1/CFDocuments/{identifier}`). */ + private static isDocumentUri(uri: string, identifier: string): boolean { + const path = uri.split(/[?#]/)[0].replace(/\/+$/, '') + const match = path.match(/\/CFDocuments\/([^/]+)$/) + if (!match) return false + try { + return decodeURIComponent(match[1]) === identifier + } catch { + return match[1] === identifier + } + } + private static createLinkDataFromString(nodeId: string, tenantId: TenantId, caseVersion: CaseVersion): LinkData { return { title: nodeId, diff --git a/apps/opencase/src/domain/case/entities/__tests__/CFAssociation.test.ts b/apps/opencase/src/domain/case/entities/__tests__/CFAssociation.test.ts index 6ef8897..599257e 100644 --- a/apps/opencase/src/domain/case/entities/__tests__/CFAssociation.test.ts +++ b/apps/opencase/src/domain/case/entities/__tests__/CFAssociation.test.ts @@ -129,6 +129,104 @@ describe('CFAssociation', () => { expect(json.destinationNodeURI.identifier).toBe('item-2'); }); + describe('node URIs pointing at a CFDocument (#44)', () => { + const docId = '11111111-1111-4111-8111-111111111111'; + const itemId = '22222222-2222-4222-8222-222222222222'; + + it('keeps a /CFDocuments/ destination when the source URI points at a CFDocument', () => { + const raw = { + identifier: '33333333-3333-4333-8333-333333333333', + associationType: 'exactMatchOf', + originNodeURI: { title: 'Mathematics', identifier: itemId, uri: `https://opencase.example.org/ims/case/v1p1/CFItems/${itemId}` }, + destinationNodeURI: { title: 'Primary Education – Mathematics', identifier: docId, uri: `https://opencase.example.org/ims/case/v1p1/CFDocuments/${docId}` } + }; + + const json = CFAssociation.fromRaw(tenantId, caseVersion, raw).toJSON(); + + expect(json.originNodeURI.uri).toBe(`/ims/case/v1p1/CFItems/${itemId}`); + expect(json.destinationNodeURI.uri).toBe(`/ims/case/v1p1/CFDocuments/${docId}`); + expect(json.destinationNodeURI.identifier).toBe(docId); + }); + + it('recognises relative, foreign-host and other-version CFDocuments URIs', () => { + for (const uri of [ + `/ims/case/v1p1/CFDocuments/${docId}`, + `https://standards.example.org/ims/case/v1p0/CFDocuments/${docId}/`, + `https://standards.example.org/ims/case/v1p1/CFDocuments/${docId}?x=1` + ]) { + const raw = { + identifier: 'assoc-1', + associationType: 'isChildOf', + originNodeURI: { title: 'Item', identifier: itemId, uri: `/ims/case/v1p1/CFItems/${itemId}` }, + destinationNodeURI: { title: 'Doc', identifier: docId, uri } + }; + expect(CFAssociation.fromRaw(tenantId, caseVersion, raw).toJSON().destinationNodeURI.uri) + .toBe(`/ims/case/v1p1/CFDocuments/${docId}`); + } + }); + + it('uses the CASE version of the package for the CFDocuments path', () => { + const raw = { + identifier: 'assoc-1', + associationType: 'isChildOf', + originNodeURI: { title: 'Item', identifier: itemId, uri: `/ims/case/v1p0/CFItems/${itemId}` }, + destinationNodeURI: { title: 'Doc', identifier: docId, uri: `/ims/case/v1p1/CFDocuments/${docId}` } + }; + expect(CFAssociation.fromRaw(tenantId, '1.0', raw).toJSON().destinationNodeURI.uri) + .toBe(`/ims/case/v1p0/CFDocuments/${docId}`); + }); + + it('treats a urn:case:document: URN as a CFDocument', () => { + const raw = { + identifier: 'assoc-1', + associationType: 'isChildOf', + originNodeURI: { title: 'Item', identifier: itemId, uri: `urn:case:item:${itemId}` }, + destinationNodeURI: { title: 'Doc', identifier: docId, uri: `urn:case:document:${docId}` } + }; + const json = CFAssociation.fromRaw(tenantId, caseVersion, raw).toJSON(); + expect(json.originNodeURI.uri).toBe(`/ims/case/v1p1/CFItems/${itemId}`); + expect(json.destinationNodeURI.uri).toBe(`/ims/case/v1p1/CFDocuments/${docId}`); + }); + + it('uses /CFDocuments/ for a known document identifier, even when the source URI says CFItems or is missing', () => { + const raw = { + identifier: 'assoc-1', + associationType: 'isChildOf', + originNodeURI: { title: 'Item', identifier: itemId, uri: `/ims/case/v1p1/CFItems/${itemId}` }, + destinationNodeURI: { title: 'Doc', identifier: docId, uri: `/ims/case/v1p1/CFItems/${docId}` } + }; + expect(CFAssociation.fromRaw(tenantId, caseVersion, raw, { documentIdentifiers: [docId] }).toJSON().destinationNodeURI.uri) + .toBe(`/ims/case/v1p1/CFDocuments/${docId}`); + + const legacy = { sourcedId: 'assoc-2', originNode: itemId, destinationNode: docId, associationType: 'isChildOf' }; + expect(CFAssociation.fromRaw(tenantId, caseVersion, legacy, { documentIdentifiers: [docId] }).toJSON().destinationNodeURI.uri) + .toBe(`/ims/case/v1p1/CFDocuments/${docId}`); + }); + + it('does not treat a CFDocuments URI for a different identifier as a document', () => { + const raw = { + identifier: 'assoc-1', + associationType: 'isChildOf', + originNodeURI: { title: 'Item', identifier: itemId, uri: `/ims/case/v1p1/CFItems/${itemId}` }, + destinationNodeURI: { title: 'Other', identifier: 'item-2', uri: `/ims/case/v1p1/CFDocuments/${docId}` } + }; + expect(CFAssociation.fromRaw(tenantId, caseVersion, raw).toJSON().destinationNodeURI.uri) + .toBe('/ims/case/v1p1/CFItems/item-2'); + }); + + it('is stable on reload: a stored /CFDocuments/ URI stays a /CFDocuments/ URI', () => { + const raw = { + identifier: 'assoc-1', + associationType: 'isChildOf', + originNodeURI: { title: 'Item', identifier: itemId, uri: `/ims/case/v1p1/CFItems/${itemId}` }, + destinationNodeURI: { title: 'Doc', identifier: docId, uri: `https://opencase.example.org/ims/case/v1p1/CFDocuments/${docId}` } + }; + const first = CFAssociation.fromRaw(tenantId, caseVersion, raw).toJSON(); + const second = CFAssociation.fromRaw(tenantId, caseVersion, first).toJSON(); + expect(second.destinationNodeURI).toEqual(first.destinationNodeURI); + }); + }); + it('should rewrite CFAssociationGroupingURI to the local host on import', () => { const raw = { sourcedId: 'assoc-123', diff --git a/apps/opencase/src/infrastructure/persistence/file/FileCFPackageRepository.ts b/apps/opencase/src/infrastructure/persistence/file/FileCFPackageRepository.ts index 46da75a..94874b9 100644 --- a/apps/opencase/src/infrastructure/persistence/file/FileCFPackageRepository.ts +++ b/apps/opencase/src/infrastructure/persistence/file/FileCFPackageRepository.ts @@ -38,7 +38,7 @@ export class FileCFPackageRepository implements CFPackageRepository { CFItem.fromRaw(tenantId, version, i, document.sourcedId, docURI, preserveUris) ) const associations = (bundle.associations ?? []).map((a: unknown) => - CFAssociation.fromRaw(tenantId, version, a, preserveUris) + CFAssociation.fromRaw(tenantId, version, a, { ...preserveUris, documentIdentifiers: [document.sourcedId] }) ) const rubrics = (bundle.rubrics ?? []).map((r: unknown) => CFRubric.fromRaw(tenantId, version, r, preserveUris) From 6716f17bd1eb08c22d66f8d04d82dc6832a20af7 Mon Sep 17 00:00:00 2001 From: Stan Billiet <99833815+stanbilliet@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:51:50 +0200 Subject: [PATCH 2/2] Keep association nodes with a non-CASE targetType as supplied (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CFAssociation.fromRaw rebuilt every origin/destination node as a local CFItems reference and dropped targetType, so links to targets outside CASE (CASE 1.1 §1.9.3) lost their original uri and targetType. - Nodes with a targetType other than 'CASE' are kept exactly as supplied (title, identifier, uri, targetType). - targetType 'CASE' is kept; the uri is still rebased locally. - toJSON('1.0') no longer strips targetType from the entity's own LinkData objects (it now copies before removing the field). --- .../src/domain/case/entities/CFAssociation.ts | 31 +++++++++--- .../entities/__tests__/CFAssociation.test.ts | 50 +++++++++++++++++++ 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/apps/opencase/src/domain/case/entities/CFAssociation.ts b/apps/opencase/src/domain/case/entities/CFAssociation.ts index 0bee4e4..3935fd3 100644 --- a/apps/opencase/src/domain/case/entities/CFAssociation.ts +++ b/apps/opencase/src/domain/case/entities/CFAssociation.ts @@ -70,7 +70,8 @@ export class CFAssociation { // like CFItem.CFDocumentURI — their uri is always regenerated to point at the // local host, regardless of what URI shape (URN, absolute foreign-host URL, or // relative path) the source supplied. Only the identifier, and whether the node - // is a CFDocument or a CFItem, are trusted from the source data. + // is a CFDocument or a CFItem, are trusted from the source data. Nodes with a + // non-CASE targetType are kept as supplied (see buildNodeLinkData). const documentIdentifiers = new Set(options?.documentIdentifiers ?? []) originNodeURI = this.buildNodeLinkData(tenantId, caseVersion, raw.originNodeURI, raw.originNode, documentIdentifiers) destinationNodeURI = this.buildNodeLinkData(tenantId, caseVersion, raw.destinationNodeURI, raw.destinationNode, documentIdentifiers) @@ -132,6 +133,19 @@ export class CFAssociation { legacyNodeId: unknown, documentIdentifiers: Set ): LinkData { + // CASE 1.1 lets an association point outside CASE (spec §1.9.3). A node whose + // targetType is anything other than 'CASE' is not served by OpenCASE, so it is + // kept exactly as supplied: original uri and targetType. + const targetType: unknown = link?.targetType + if (typeof targetType === 'string' && targetType !== 'CASE' && link?.uri) { + return { + title: link.title ?? String(link.identifier ?? legacyNodeId ?? link.uri), + identifier: link.identifier ?? legacyNodeId ?? link.uri, + uri: link.uri, + targetType + } + } + let id = link?.identifier ?? legacyNodeId ?? 'unknown' const uriFromSource: unknown = link?.uri let isDocument = false @@ -148,13 +162,15 @@ export class CFAssociation { if (documentIdentifiers.has(String(id))) isDocument = true - return { + const result: LinkData = { title: link?.title ?? String(id), identifier: id, uri: isDocument ? this.generateDocumentURI(tenantId, caseVersion, id) : this.generateItemURI(tenantId, caseVersion, id) } + if (targetType === 'CASE') result.targetType = targetType + return result } /** True when `uri` is a CASE CFDocuments URI for `identifier` (e.g. `…/ims/case/v1p1/CFDocuments/{identifier}`). */ @@ -197,10 +213,13 @@ export class CFAssociation { if (effectiveVersion === '1.0') { delete result.notes delete result.extensions - if (result.originNodeURI && typeof result.originNodeURI === 'object') delete result.originNodeURI.targetType - if (result.destinationNodeURI && typeof result.destinationNodeURI === 'object') delete result.destinationNodeURI.targetType - if (result.CFDocumentURI && typeof result.CFDocumentURI === 'object') delete result.CFDocumentURI.targetType - if (result.CFAssociationGroupingURI && typeof result.CFAssociationGroupingURI === 'object') delete result.CFAssociationGroupingURI.targetType + // Copy before stripping so the entity's own LinkData keeps targetType. + for (const key of ['originNodeURI', 'destinationNodeURI', 'CFDocumentURI', 'CFAssociationGroupingURI']) { + if (result[key] && typeof result[key] === 'object') { + const { targetType: _targetType, ...link } = result[key] + result[key] = link + } + } } return result; diff --git a/apps/opencase/src/domain/case/entities/__tests__/CFAssociation.test.ts b/apps/opencase/src/domain/case/entities/__tests__/CFAssociation.test.ts index 599257e..bb3577c 100644 --- a/apps/opencase/src/domain/case/entities/__tests__/CFAssociation.test.ts +++ b/apps/opencase/src/domain/case/entities/__tests__/CFAssociation.test.ts @@ -227,6 +227,56 @@ describe('CFAssociation', () => { }); }); + describe('nodes outside CASE (targetType, #49)', () => { + const itemId = '22222222-2222-4222-8222-222222222222'; + const external = { + title: 'External goal 1.2.3', + identifier: '44444444-4444-4444-8444-444444444444', + uri: 'https://standards.example.org/goals/1.2.3', + targetType: 'ext:external' + }; + const raw = { + identifier: '55555555-5555-4555-8555-555555555555', + associationType: 'isRelatedTo', + originNodeURI: { title: 'Item', identifier: itemId, uri: `/ims/case/v1p1/CFItems/${itemId}` }, + destinationNodeURI: external + }; + + it('keeps a non-CASE destination exactly as supplied', () => { + const json = CFAssociation.fromRaw(tenantId, caseVersion, raw).toJSON(); + expect(json.destinationNodeURI).toEqual(external); + expect(json.originNodeURI.uri).toBe(`/ims/case/v1p1/CFItems/${itemId}`); + }); + + it('round-trips unchanged (fromRaw → toJSON → fromRaw)', () => { + const first = CFAssociation.fromRaw(tenantId, caseVersion, raw).toJSON(); + const second = CFAssociation.fromRaw(tenantId, caseVersion, first).toJSON(); + expect(second).toEqual(first); + }); + + it('keeps targetType CASE and still rebases the uri onto the local host', () => { + const json = CFAssociation.fromRaw(tenantId, caseVersion, { + ...raw, + destinationNodeURI: { title: 'Item 2', identifier: 'item-2', uri: 'https://standards.example.org/ims/case/v1p1/CFItems/item-2', targetType: 'CASE' } + }).toJSON(); + expect(json.destinationNodeURI).toEqual({ + title: 'Item 2', identifier: 'item-2', uri: '/ims/case/v1p1/CFItems/item-2', targetType: 'CASE' + }); + }); + + it('drops targetType when serialized as CASE 1.0', () => { + const json = CFAssociation.fromRaw(tenantId, caseVersion, raw).toJSON('1.0'); + expect(json.destinationNodeURI.targetType).toBeUndefined(); + expect(json.destinationNodeURI.uri).toBe(external.uri); + }); + + it('does not lose targetType on the entity after a CASE 1.0 serialization', () => { + const assoc = CFAssociation.fromRaw(tenantId, caseVersion, raw); + assoc.toJSON('1.0'); + expect(assoc.toJSON().destinationNodeURI.targetType).toBe('ext:external'); + }); + }); + it('should rewrite CFAssociationGroupingURI to the local host on import', () => { const raw = { sourcedId: 'assoc-123',