import { describe, expect, it } from "vitest";
import { buildDbModelForNamespace, detectRenamedEntityLabels, findRootModel } from "./csn-model-builder";
import type { TCsnContent } from "./csn-model-types";
/** Hand-built 2-level composition tree: `TestObject` (root) → `to_Child` (many) → `ChildObject`. */
function makeCsnFixture(): TCsnContent {
return {
definitions: {
"MDG_TST.TestObject": {
"@sap.label": "TestObject",
elements: {
objectID: { type: "cds.String", length: 10, key: true },
name: { type: "cds.String", length: 40 },
isActive: { type: "cds.Boolean" },
to_Child: {
type: "cds.Composition",
target: "MDG_TST.ChildObject",
cardinality: { max: "*" },
on: [{ ref: ["to_Child", "linkKey"] }, "=", { ref: ["linkKey"] }],
},
},
},
"MDG_TST.ChildObject": {
"@sap.label": "ChildObject",
elements: {
objectID: { type: "cds.String", length: 10, key: true },
childID: { type: "cds.String", length: 10, key: true },
linkKey: { type: "cds.String", length: 10 },
description: { type: "cds.String", length: 80 },
},
},
},
};
}
function findAction(actions: { file_path: string; content?: string }[], filePath: string): string {
const action = actions.find((item) => item.file_path === filePath);
if (!action?.content) throw new Error(`Expected a "${filePath}" action, got: ${actions.map((item) => item.file_path).join(", ")}`);
return action.content;
}
/**
* The real formatter (`@sap/cds-lsp`, see `cds-pretty-print.ts`) column-aligns field/relation
* declarations by padding with a run of spaces sized to the widest identifier in the block — exact
* padding is content-dependent and not something tests should hardcode. Collapsing whitespace runs
* to a single space (on both sides) makes assertions robust to that dynamic padding while still
* verifying real content and line structure (newlines are preserved, not collapsed).
*/
function normalizeSpaces(text: string): string {
return text.replace(/[ \t]+/g, " ");
}
function expectNormalizedToContain(content: string, expected: string): void {
expect(normalizeSpaces(content)).toContain(normalizeSpaces(expected));
}
describe("findRootModel", () => {
it("derives the short code from the CSN's own root entity instead of a configured short name", () => {
const result = findRootModel(makeCsnFixture(), "TestObject");
expect(result).toEqual({ rootModelName: "MDG_TST.TestObject", shortName: "TST" });
});
it("throws when no definition matches the requested object type", () => {
expect(() => findRootModel(makeCsnFixture(), "SomethingElse")).toThrow(/Cannot find a root entity/);
});
it("throws (rather than silently picking the first one) when two different definitions share the root's @sap.label", () => {
const csn: TCsnContent = {
definitions: {
"MDG_TST.TestObject": { "@sap.label": "TestObject", elements: { objectID: { type: "cds.String", length: 10, key: true } } },
"MDG_TST2.TestObject": { "@sap.label": "TestObject", elements: { objectID: { type: "cds.String", length: 10, key: true } } },
},
};
expect(() => findRootModel(csn, "TestObject")).toThrow(/Found 2 definitions.*MDG_TST\.TestObject.*MDG_TST2\.TestObject/s);
});
});
describe("buildDbModelForNamespace — eventmesh mode", () => {
const csn = makeCsnFixture();
const result = buildDbModelForNamespace("final", csn, "MDG_TST.TestObject", "TestObject", "TST", "eventmesh");
it("produces final + staging files for every level, no multi-ERP srv actions", () => {
expect(result.dbActions.map((a) => a.file_path)).toEqual(["db/final/1st-model.cds", "db/staging/1st-model.cds", "db/final/2nd-model.cds", "db/staging/2nd-model.cds"]);
expect(result.srvActions).toEqual([]);
});
it("renders the root entity with correct namespace, base aspect, fields, and relation", () => {
const content = findAction(result.dbActions, "db/final/1st-model.cds");
expect(content).toContain("namespace tst.model.final;");
expect(content).toContain("entity TestObject : business_1st_level_entity {");
expectNormalizedToContain(content, "key objectID : String(10) @(title : '{i18n>TestObject.objectID}');");
expectNormalizedToContain(content, "name : String(40) @(title : '{i18n>TestObject.name}');");
// eventmesh mode: Boolean has no `default false` (only non-eventmesh-family modes get that).
expectNormalizedToContain(content, "isActive : Boolean @(title : '{i18n>TestObject.isActive}');");
expect(content).toContain("to_Child : Composition of many ChildObject");
// Namespace identity join (objectID, from DB_NAMESPACE_CONFIG.final) + the CSN's own on-condition
// (linkKey), one clause per line (matches legacy's line structure, avoids diff noise from a flat join).
expectNormalizedToContain(content, "on to_Child.objectID = $self.objectID\n and to_Child.linkKey = $self.linkKey");
expectNormalizedToContain(content, "using {tst.model.final.ChildObject} from './2nd-model';");
});
it("renders the staging entity re-scoping the relation with an inserted taskID join, no restated fields", () => {
const content = findAction(result.dbActions, "db/staging/1st-model.cds");
expect(content).toContain("namespace tst.model.staging;");
expect(content).toContain("entity TestObject : final_TestObject, business_entity_staging {");
expectNormalizedToContain(content, "on to_Child.objectID = $self.objectID\n and to_Child.taskID = $self.taskID\n and to_Child.linkKey = $self.linkKey");
// Scalar fields are inherited from `final_TestObject`, never restated in staging.
expect(content).not.toContain("name : String(40)");
});
it("renders the leaf child entity at the next level", () => {
const content = findAction(result.dbActions, "db/final/2nd-model.cds");
expect(content).toContain("entity ChildObject : business_child_level_entity {");
expectNormalizedToContain(content, "key childID : String(10)");
expectNormalizedToContain(content, "description : String(80)");
});
});
describe("buildDbModelForNamespace — matches a real customer's multi-key composition (CustomerMaterialInfoRecord)", () => {
// Reproduces the exact shape of a live `simplemdg_db_cmi` composition: the CSN's own on-condition
// carries 3 "and"-joined business-key segments (not just 1), on top of the namespace's own
// `objectID` identity join — verified against the real generated `db/final/1st-model.cds`:
// to_CMII on to_CMII.objectID = $self.objectID and to_CMII.customer = $self.customer
// and to_CMII.distributionChannel = $self.distributionChannel and to_CMII.salesOrganization = $self.salesOrganization;
it("joins every CSN-provided segment after the namespace identity join, in order", () => {
const csn: TCsnContent = {
definitions: {
"MDG_CMI.CustomerMaterialInfoRecord": {
"@sap.label": "CustomerMaterialInfoRecord",
elements: {
salesOrganization: { type: "cds.String", length: 4, key: true },
distributionChannel: { type: "cds.String", length: 2, key: true },
customer: { type: "cds.String", length: 10, key: true },
to_CMII: {
type: "cds.Composition",
target: "MDG_CMI.CustomerMaterialInfoRecordItem",
cardinality: { max: "*" },
on: [
{ ref: ["to_CMII", "customer"] },
"=",
{ ref: ["customer"] },
"and",
{ ref: ["to_CMII", "distributionChannel"] },
"=",
{ ref: ["distributionChannel"] },
"and",
{ ref: ["to_CMII", "salesOrganization"] },
"=",
{ ref: ["salesOrganization"] },
],
},
},
},
"MDG_CMI.CustomerMaterialInfoRecordItem": {
"@sap.label": "CustomerMaterialInfoRecordItem",
elements: { material: { type: "cds.String", length: 40 } },
},
},
};
const result = buildDbModelForNamespace("final", csn, "MDG_CMI.CustomerMaterialInfoRecord", "CustomerMaterialInfoRecord", "CMI", "eventmesh");
const content = findAction(result.dbActions, "db/final/1st-model.cds");
expectNormalizedToContain(
content,
[
"on to_CMII.objectID = $self.objectID",
"and to_CMII.customer = $self.customer",
"and to_CMII.distributionChannel = $self.distributionChannel",
"and to_CMII.salesOrganization = $self.salesOrganization",
].join("\n "),
);
});
});
describe("buildDbModelForNamespace — multiple_erp mode", () => {
const csn = makeCsnFixture();
const result = buildDbModelForNamespace("final", csn, "MDG_TST.TestObject", "TestObject", "TST", "multiple_erp");
it("generates a Mapping entity + to_
Mapping composition per business table with keys", () => {
const content = findAction(result.dbActions, "db/final/1st-model.cds");
expect(content).toContain("entity TestObjectMapping : mapping_entity {");
expectNormalizedToContain(content, "key ERPSystem : String;");
// multiple_erp mode drops the length suffix entirely (`buildTypeByFieldConfig`'s bare-type convention).
expectNormalizedToContain(content, "key objectID : String @(title : '{i18n>TestObject.objectID}');");
expectNormalizedToContain(content, "ERPname : String @(title : '{i18n>TestObject.name}');");
expect(content).toContain("to_TestObjectMapping : Composition of many TestObjectMapping");
expectNormalizedToContain(content, "on to_TestObjectMapping.objectID = $self.objectID\n and to_TestObjectMapping.objectID = $self.objectID;");
});
it("generates srv/master-data-service.cds exposing every business + Mapping table across all levels", () => {
expect(result.srvActions).toHaveLength(1);
const content = result.srvActions[0].content ?? "";
expect(content).toContain("namespace tst.service.masterdata;");
expect(content).toContain("using tst.model.final as tst_1st from '@simplemdg/db_tst/db/final/1st-model';");
expect(content).toContain("using tst.model.final as tst_2nd from '@simplemdg/db_tst/db/final/2nd-model';");
// The real formatter reflows the `@(requires: [...]) @(path: ...)` annotation pair across
// multiple lines — check its pieces rather than one exact joined string.
expectNormalizedToContain(content, "service TestObjectMasterDataService @(requires : [");
expect(content).toContain("'MD_TestObject',");
expect(content).toContain("'system-user'");
expectNormalizedToContain(content, "@(path : '/TestObjectMasterDataService') {");
expectNormalizedToContain(content, "entity TestObject as projection on tst_1st.TestObject;");
expectNormalizedToContain(content, "entity ChildObjectMapping as projection on tst_2nd.ChildObjectMapping;");
});
});
describe("buildDbModelForNamespace — validation (fail-fast)", () => {
it("throws immediately when the root entity's @sap.label doesn't match the requested object type", () => {
const csn = makeCsnFixture();
expect(() => buildDbModelForNamespace("final", csn, "MDG_TST.TestObject", "SomethingElse", "TST", "eventmesh")).toThrow(/@sap\.label !== SomethingElse/);
});
it("throws immediately when the root model is missing entirely", () => {
const csn = makeCsnFixture();
expect(() => buildDbModelForNamespace("final", csn, "MDG_TST.DoesNotExist", "TestObject", "TST", "eventmesh")).toThrow(/Missing table/);
});
it("aggregates a missing @sap.label on a non-root entity into one error and builds no CommitActions", () => {
const csn = makeCsnFixture();
delete csn.definitions["MDG_TST.ChildObject"]["@sap.label"];
let thrown: unknown;
try {
buildDbModelForNamespace("final", csn, "MDG_TST.TestObject", "TestObject", "TST", "eventmesh");
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(Error);
expect((thrown as Error).message).toContain("Missing @sap.label");
expect((thrown as Error).message).toContain("MDG_TST.ChildObject");
});
});
describe("buildDbModelForNamespace — composition with no `on` tokens (modern cds-dk EDMX import)", () => {
// Reproduces a real gap found in a live customer upload: `@sap/cds-dk@9.9.2`'s EDMX importer
// leaves compositions with no `` as `{ ..., keys: [] }` — no `on` array at
// all — even for the ordinary "child repeats parent's business keys" MDG pattern. Also reproduces
// the real customer's exact shape where the shared field is NOT itself a key on the child
// (`AdditionalCMIRItem.customer` is a plain field there, only `alternativeMatByCustomer` is a key).
it("falls back to matching the parent's key fields against ANY field on the child, not just the child's own keys", () => {
const csn: TCsnContent = {
definitions: {
"MDG_TST.TestObject": {
"@sap.label": "TestObject",
elements: {
objectID: { type: "cds.String", length: 10, key: true },
customer: { type: "cds.String", length: 10, key: true },
to_Extra: { type: "cds.Composition", target: "MDG_TST.ExtraObject", cardinality: { max: "*" }, keys: [] },
},
},
"MDG_TST.ExtraObject": {
"@sap.label": "ExtraObject",
elements: {
noteID: { type: "cds.String", length: 10, key: true },
// Shares the parent's `customer` field NAME, but does not mark it as a key here.
customer: { type: "cds.String", length: 10 },
},
},
},
};
const result = buildDbModelForNamespace("final", csn, "MDG_TST.TestObject", "TestObject", "TST", "eventmesh");
const content = result.dbActions.find((a) => a.file_path === "db/final/1st-model.cds")?.content ?? "";
expect(content).toContain("to_Extra : Composition of many ExtraObject");
expectNormalizedToContain(content, "on to_Extra.objectID = $self.objectID\n and to_Extra.customer = $self.customer");
});
it("still skips the relation when there's truly no name overlap at all (rather than emitting a join-less composition)", () => {
const csn: TCsnContent = {
definitions: {
"MDG_TST.TestObject": {
"@sap.label": "TestObject",
elements: {
objectID: { type: "cds.String", length: 10, key: true },
to_Unrelated: { type: "cds.Composition", target: "MDG_TST.UnrelatedObject", cardinality: { max: "*" }, keys: [] },
},
},
"MDG_TST.UnrelatedObject": {
"@sap.label": "UnrelatedObject",
elements: { somethingElse: { type: "cds.String", length: 10, key: true } },
},
},
};
const result = buildDbModelForNamespace("final", csn, "MDG_TST.TestObject", "TestObject", "TST", "eventmesh");
const content = result.dbActions.find((a) => a.file_path === "db/final/1st-model.cds")?.content ?? "";
expect(content).not.toContain("to_Unrelated");
expect(content).not.toContain("UnrelatedObject");
});
});
describe("buildDbModelForNamespace — joinRisks (early warning for no-ReferentialConstraint compositions)", () => {
it("reports a critical finding when a parent key has no match by name or label on the child", () => {
const csn: TCsnContent = {
definitions: {
"MDG_TST.TestObject": {
"@sap.label": "TestObject",
elements: {
objectID: { type: "cds.String", length: 10, key: true },
to_Unrelated: { type: "cds.Composition", target: "MDG_TST.UnrelatedObject", cardinality: { max: "*" }, keys: [] },
},
},
"MDG_TST.UnrelatedObject": {
"@sap.label": "UnrelatedObject",
elements: { somethingElse: { type: "cds.String", length: 10, key: true } },
},
},
};
const result = buildDbModelForNamespace("final", csn, "MDG_TST.TestObject", "TestObject", "TST", "eventmesh");
expect(result.joinRisks).toEqual([
expect.objectContaining({ severity: "critical", outcome: "dropped-no-suggestion", parentKeyField: "objectID", targetBusinessTable: "UnrelatedObject" }),
]);
});
it("reports a high-severity finding with a suggested field when the child has a differently-named field sharing the same @sap.label", () => {
const csn: TCsnContent = {
definitions: {
"MDG_TST.TestObject": {
"@sap.label": "TestObject",
elements: {
objectID: { type: "cds.String", length: 10, key: true },
objectClass: { type: "cds.String", length: 1, key: true, "@sap.label": "Ind.: Object/Class" },
to_Detail: { type: "cds.Composition", target: "MDG_TST.DetailObject", cardinality: { max: "*" }, keys: [] },
},
},
"MDG_TST.DetailObject": {
"@sap.label": "DetailObject",
elements: {
objectID: { type: "cds.String", length: 10 },
indicatorObj: { type: "cds.String", length: 1, "@sap.label": "Ind.: Object/Class" },
},
},
},
};
const result = buildDbModelForNamespace("final", csn, "MDG_TST.TestObject", "TestObject", "TST", "eventmesh");
expect(result.joinRisks).toContainEqual(
expect.objectContaining({
severity: "high",
outcome: "dropped-with-label-suggestion",
parentKeyField: "objectClass",
targetBusinessTable: "DetailObject",
message: expect.stringContaining("indicatorObj"),
}),
);
});
it("reports a medium-severity finding when a same-named field exists on both sides but its @sap.label differs", () => {
const csn: TCsnContent = {
definitions: {
"MDG_TST.TestObject": {
"@sap.label": "TestObject",
elements: {
objectID: { type: "cds.String", length: 10, key: true },
counter: { type: "cds.String", length: 4, key: true, "@sap.label": "Int. counter" },
to_Detail: { type: "cds.Composition", target: "MDG_TST.DetailObject", cardinality: { max: "*" }, keys: [] },
},
},
"MDG_TST.DetailObject": {
"@sap.label": "DetailObject",
elements: {
objectID: { type: "cds.String", length: 10 },
counter: { type: "cds.String", length: 3, "@sap.label": "Characteristic value counter" },
},
},
},
};
const result = buildDbModelForNamespace("final", csn, "MDG_TST.TestObject", "TestObject", "TST", "eventmesh");
expect(result.joinRisks).toContainEqual(
expect.objectContaining({ severity: "medium", outcome: "label-mismatch", parentKeyField: "counter", targetBusinessTable: "DetailObject" }),
);
});
it("reports an info finding (not a blocker) when KEY_INTERSECTION_FIELD_OVERRIDES already resolves the mismatch", () => {
const csn: TCsnContent = {
definitions: {
"MDG_CMI.CMIRItemClassif": {
"@sap.label": "CMIRItemClassif",
elements: {
objectID: { type: "cds.String", length: 10, key: true },
objectClass: { type: "cds.String", length: 1, key: true },
counter: { type: "cds.String", length: 4, key: true },
to_CMICharac: { type: "cds.Composition", target: "MDG_CMI.Characteristics", cardinality: { max: "*" }, keys: [] },
},
},
"MDG_CMI.Characteristics": {
"@sap.label": "Characteristics",
elements: {
objectID: { type: "cds.String", length: 10 },
indicatorObj: { type: "cds.String", length: 1 },
intCounter: { type: "cds.String", length: 4 },
},
},
},
};
const result = buildDbModelForNamespace("final", csn, "MDG_CMI.CMIRItemClassif", "CMIRItemClassif", "CMI", "eventmesh");
expect(result.joinRisks).toHaveLength(2);
expect(result.joinRisks).toContainEqual(expect.objectContaining({ severity: "info", outcome: "resolved-by-override", parentKeyField: "objectClass" }));
expect(result.joinRisks).toContainEqual(expect.objectContaining({ severity: "info", outcome: "resolved-by-override", parentKeyField: "counter" }));
const content = result.dbActions.find((a) => a.file_path === "db/final/1st-model.cds")?.content ?? "";
expectNormalizedToContain(content, "and to_CMICharac.indicatorObj = $self.objectClass");
expectNormalizedToContain(content, "and to_CMICharac.intCounter = $self.counter");
});
it("produces no findings when the composition has a real ReferentialConstraint-derived `on` condition", () => {
const result = buildDbModelForNamespace("final", makeCsnFixture(), "MDG_TST.TestObject", "TestObject", "TST", "eventmesh");
expect(result.joinRisks).toEqual([]);
});
});
describe("buildDbModelForNamespace — joinRisks (structural anomalies beyond join-key matching)", () => {
it("flags a composition/association whose property name doesn't start with `to_` and omits it from the generated model", () => {
const csn: TCsnContent = {
definitions: {
"MDG_TST.TestObject": {
"@sap.label": "TestObject",
elements: {
objectID: { type: "cds.String", length: 10, key: true },
childRef: { type: "cds.Composition", target: "MDG_TST.ChildObject", cardinality: { max: "*" }, on: [{ ref: ["childRef", "objectID"] }, "=", { ref: ["objectID"] }] },
},
},
"MDG_TST.ChildObject": {
"@sap.label": "ChildObject",
elements: { objectID: { type: "cds.String", length: 10, key: true } },
},
},
};
const result = buildDbModelForNamespace("final", csn, "MDG_TST.TestObject", "TestObject", "TST", "eventmesh");
expect(result.joinRisks).toContainEqual(
expect.objectContaining({ severity: "critical", outcome: "non-standard-relation-name", relationName: "childRef", targetBusinessTable: "ChildObject" }),
);
const content = result.dbActions.find((a) => a.file_path === "db/final/1st-model.cds")?.content ?? "";
expect(content).not.toContain("childRef");
expect(content).not.toContain("ChildObject");
});
it("flags a composition cycle back to a non-immediate ancestor and skips it instead of recursing forever", () => {
// 3 levels deep: TestObject -> MidObject -> LeafObject -> back to TestObject. LeafObject's
// immediate parent is MidObject, not TestObject, so the existing `target === parentModelName`
// guard (which only catches an immediate child<->parent back-reference) does NOT catch this —
// only the broader ancestor-chain check does.
const csn: TCsnContent = {
definitions: {
"MDG_TST.TestObject": {
"@sap.label": "TestObject",
elements: {
objectID: { type: "cds.String", length: 10, key: true },
to_Mid: { type: "cds.Composition", target: "MDG_TST.MidObject", cardinality: { max: "*" }, on: [{ ref: ["to_Mid", "objectID"] }, "=", { ref: ["objectID"] }] },
},
},
"MDG_TST.MidObject": {
"@sap.label": "MidObject",
elements: {
objectID: { type: "cds.String", length: 10, key: true },
to_Leaf: { type: "cds.Composition", target: "MDG_TST.LeafObject", cardinality: { max: "*" }, on: [{ ref: ["to_Leaf", "objectID"] }, "=", { ref: ["objectID"] }] },
},
},
"MDG_TST.LeafObject": {
"@sap.label": "LeafObject",
elements: {
objectID: { type: "cds.String", length: 10, key: true },
// Points back to the ROOT — an ancestor 2 levels up, not its immediate parent (MidObject).
to_Root: { type: "cds.Composition", target: "MDG_TST.TestObject", cardinality: { max: "*" }, on: [{ ref: ["to_Root", "objectID"] }, "=", { ref: ["objectID"] }] },
},
},
},
};
const result = buildDbModelForNamespace("final", csn, "MDG_TST.TestObject", "TestObject", "TST", "eventmesh");
expect(result.joinRisks).toContainEqual(
expect.objectContaining({ severity: "critical", outcome: "composition-cycle", relationName: "to_Root", parentBusinessTable: "LeafObject", targetBusinessTable: "TestObject" }),
);
// Didn't hang/stack-overflow, and all 3 real levels were still generated.
expect(result.dbActions.some((a) => a.file_path === "db/final/3rd-model.cds")).toBe(true);
expect(result.dbActions.some((a) => a.file_path === "db/final/4th-model.cds")).toBe(false);
});
it("flags a composition/association whose target has no resolvable @sap.label instead of emitting invalid CDS", () => {
const csn: TCsnContent = {
definitions: {
"MDG_TST.TestObject": {
"@sap.label": "TestObject",
elements: {
objectID: { type: "cds.String", length: 10, key: true },
to_Ghost: { type: "cds.Composition", target: "MDG_TST.DoesNotExist", cardinality: { max: "*" }, on: [{ ref: ["to_Ghost", "objectID"] }, "=", { ref: ["objectID"] }] },
},
},
},
};
const result = buildDbModelForNamespace("final", csn, "MDG_TST.TestObject", "TestObject", "TST", "eventmesh");
expect(result.joinRisks).toContainEqual(
expect.objectContaining({ severity: "critical", outcome: "dangling-target", relationName: "to_Ghost", targetBusinessTable: "MDG_TST.DoesNotExist" }),
);
const content = result.dbActions.find((a) => a.file_path === "db/final/1st-model.cds")?.content ?? "";
expect(content).not.toContain("undefined");
expect(content).not.toContain("to_Ghost");
});
it("renders the child entity only once when two different relations on the same parent target it", () => {
const csn: TCsnContent = {
definitions: {
"MDG_TST.TestObject": {
"@sap.label": "TestObject",
elements: {
objectID: { type: "cds.String", length: 10, key: true },
to_CreatedBy: { type: "cds.Composition", target: "MDG_TST.UserObject", cardinality: { max: "1" }, on: [{ ref: ["to_CreatedBy", "objectID"] }, "=", { ref: ["objectID"] }] },
to_ChangedBy: { type: "cds.Composition", target: "MDG_TST.UserObject", cardinality: { max: "1" }, on: [{ ref: ["to_ChangedBy", "objectID"] }, "=", { ref: ["objectID"] }] },
},
},
"MDG_TST.UserObject": {
"@sap.label": "UserObject",
elements: { objectID: { type: "cds.String", length: 10, key: true } },
},
},
};
const result = buildDbModelForNamespace("final", csn, "MDG_TST.TestObject", "TestObject", "TST", "eventmesh");
const rootContent = result.dbActions.find((a) => a.file_path === "db/final/1st-model.cds")?.content ?? "";
expect(rootContent).toContain("to_CreatedBy");
expect(rootContent).toContain("to_ChangedBy");
const childContent = result.dbActions.find((a) => a.file_path === "db/final/2nd-model.cds")?.content ?? "";
expect(childContent.match(/entity UserObject\b/g)).toHaveLength(1);
});
});
describe("detectRenamedEntityLabels", () => {
// Reproduces a real production incident: the source SAP system relabeled two entities
// (`CMIRItemTextType`'s @sap.label from "CMIRItemText" to "CMIItemText", and
// `CMIRItemClassificationType`'s from "CMIRItemClassification" to "CMIRItemClassif") while their
// EDMX EntityType technical names stayed identical. Because this tool names generated CDS entities
// after `@sap.label`, the redeploy silently created differently-named, empty entities instead of
// updating the existing ones — orphaning the real HANA tables backing the old names.
it("flags an entity whose @sap.label changed while its technical name stayed the same", () => {
const previousCsn: TCsnContent = {
definitions: {
"MDG_CMI.CMIRItemText": { "@sap.label": "CMIRItemText", elements: { textObject: { type: "cds.String", length: 10, key: true } } },
"MDG_CMI.CMIRItemClassification": { "@sap.label": "CMIRItemClassification", elements: { object: { type: "cds.String", length: 90, key: true } } },
"MDG_CMI.CustomerMaterialInfoRecord": { "@sap.label": "CustomerMaterialInfoRecord", elements: { objectID: { type: "cds.String", length: 10, key: true } } },
},
};
const newCsn: TCsnContent = {
definitions: {
"MDG_CMI.CMIRItemText": { "@sap.label": "CMIItemText", elements: { textObject: { type: "cds.String", length: 10, key: true } } },
"MDG_CMI.CMIRItemClassification": { "@sap.label": "CMIRItemClassif", elements: { object: { type: "cds.String", length: 90, key: true } } },
"MDG_CMI.CustomerMaterialInfoRecord": { "@sap.label": "CustomerMaterialInfoRecord", elements: { objectID: { type: "cds.String", length: 10, key: true } } },
},
};
const renamed = detectRenamedEntityLabels(previousCsn, newCsn);
expect(renamed).toEqual([
{ technicalName: "CMIRItemText", oldLabel: "CMIRItemText", newLabel: "CMIItemText" },
{ technicalName: "CMIRItemClassification", oldLabel: "CMIRItemClassification", newLabel: "CMIRItemClassif" },
]);
});
it("reports nothing when labels are unchanged", () => {
const csn: TCsnContent = {
definitions: {
"MDG_TST.TestObject": { "@sap.label": "TestObject", elements: { objectID: { type: "cds.String", length: 10, key: true } } },
},
};
expect(detectRenamedEntityLabels(csn, csn)).toEqual([]);
});
it("reports nothing when there is no previous deploy to compare against", () => {
const csn: TCsnContent = {
definitions: {
"MDG_TST.TestObject": { "@sap.label": "TestObject", elements: { objectID: { type: "cds.String", length: 10, key: true } } },
},
};
expect(detectRenamedEntityLabels(undefined, csn)).toEqual([]);
});
});
describe("buildDbModelForNamespace — custom-model.cds preservation", () => {
it("re-injects a preserved custom-model.cds attachment into the regenerated entity + its import line", () => {
const csn = makeCsnFixture();
const customModelPreservation = {
final: {
byParentEntity: {
TestObject: {
importLines: ["using {tst.model.final.TestObjectCustom} from './custom-model.cds';"],
compositionLines: ["to_TestObjectCustom : Composition of one TestObjectCustom", "on to_TestObjectCustom.objectID = $self.objectID", "and to_TestObjectCustom.name = $self.name;"],
},
},
},
staging: {
byParentEntity: {
TestObject: {
importLines: ["using {tst.model.staging.TestObjectCustom} from './custom-model.cds';"],
compositionLines: [
"to_TestObjectCustom : Composition of one TestObjectCustom",
"on to_TestObjectCustom.objectID = $self.objectID",
"and to_TestObjectCustom.taskID = $self.taskID",
"and to_TestObjectCustom.name = $self.name;",
],
},
},
},
};
const result = buildDbModelForNamespace("final", csn, "MDG_TST.TestObject", "TestObject", "TST", "eventmesh", customModelPreservation);
const finalContent = findAction(result.dbActions, "db/final/1st-model.cds");
expectNormalizedToContain(finalContent, "using {tst.model.final.TestObjectCustom} from './custom-model.cds';");
expectNormalizedToContain(finalContent, "to_TestObjectCustom : Composition of one TestObjectCustom");
expectNormalizedToContain(finalContent, "on to_TestObjectCustom.objectID = $self.objectID\n and to_TestObjectCustom.name = $self.name;");
const stagingContent = findAction(result.dbActions, "db/staging/1st-model.cds");
expectNormalizedToContain(stagingContent, "using {tst.model.staging.TestObjectCustom} from './custom-model.cds';");
expectNormalizedToContain(stagingContent, "and to_TestObjectCustom.taskID = $self.taskID");
expect(result.customModelWarnings).toEqual([]);
});
it("reports a warning instead of silently dropping an attachment whose parent entity no longer exists in this upload", () => {
const csn = makeCsnFixture();
const customModelPreservation = {
final: {
byParentEntity: {
SomeVanishedEntity: {
importLines: ["using {tst.model.final.VanishedCustom} from './custom-model.cds';"],
compositionLines: ["to_VanishedCustom : Composition of one VanishedCustom on to_VanishedCustom.objectID = $self.objectID;"],
},
},
},
};
const result = buildDbModelForNamespace("final", csn, "MDG_TST.TestObject", "TestObject", "TST", "eventmesh", customModelPreservation);
expect(result.customModelWarnings).toHaveLength(1);
expect(result.customModelWarnings[0].businessTable).toBe("SomeVanishedEntity");
expect(result.customModelWarnings[0].message).toMatch(/could not be re-applied/);
const finalContent = findAction(result.dbActions, "db/final/1st-model.cds");
expect(finalContent).not.toContain("VanishedCustom");
});
});