fix(career): preserve source ids and absent metadata

This commit is contained in:
cesnimda
2026-10-12 08:24:23 +02:00
parent 8b4d5c2112
commit 3047b6c4a8
15 changed files with 3458 additions and 21 deletions
+19 -2
View File
@@ -348,6 +348,17 @@ test('profile page loads persisted structured cv and can re-parse it', async ()
});
test('saving the career profile PUTs the structured profile and cv text unchanged (Phase 1 refactor invariant)', async () => {
const keyedProfile = {
...structuredCv,
jobs: [{ ...structuredCv.jobs[0], id: 'recorded-job-id' }],
education: [{ id: 'recorded-education-id', qualification: 'Recorded degree', details: ['Recorded distinction'] }],
certifications: [{ id: 'recorded-certification-id', name: 'Recorded certification', details: ['Recorded detail'] }],
projects: [{ id: 'recorded-project-id', name: 'Recorded project', bullets: ['Recorded outcome'], skills: ['SQL'] }],
};
const originalGet = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string) => url === '/career/profile'
? Promise.resolve({ data: { profile: keyedProfile, cvText: 'Professional Summary\nBuilt backend systems', completeness: { percent: 70, missing: [], sections: [] } } } as any)
: originalGet(url));
renderPage();
const saveButton = await screen.findByRole('button', { name: /save changes/i });
@@ -358,7 +369,13 @@ test('saving the career profile PUTs the structured profile and cv text unchange
expect(mockedApi.put).toHaveBeenCalledWith(
'/career/profile',
expect.objectContaining({
profile: expect.objectContaining({ contact: expect.objectContaining({ fullName: 'Demo User' }) }),
profile: expect.objectContaining({
contact: expect.objectContaining({ fullName: 'Demo User' }),
jobs: [expect.objectContaining(keyedProfile.jobs[0])],
education: [expect.objectContaining(keyedProfile.education[0])],
certifications: [expect.objectContaining(keyedProfile.certifications[0])],
projects: [expect.objectContaining(keyedProfile.projects[0])],
}),
cvText: 'Professional Summary\nBuilt backend systems',
}),
);
@@ -370,7 +387,7 @@ test('editing a field in an extracted section updates parent state and flows int
// Renders existing data through the extracted PersonalInformationSection.
const nameField = await screen.findByLabelText(/full name/i);
expect(nameField).toHaveValue('Demo User');
await waitFor(() => expect(nameField).toHaveValue('Demo User'));
// Editing the child field updates the parent's structuredCv (controlled input reflects it back).
fireEvent.change(nameField, { target: { value: 'Edited Name' } });
+75
View File
@@ -0,0 +1,75 @@
import { normalizeStructuredCv, parseStructuredCvJson } from "./profileCv";
const keyedItems = [
["jobs", { id: "job:recorded-Æ_01", title: "Engineer", bullets: ["Recorded delivery"], skills: ["C#"] }],
["education", { id: "education/server:key-02", qualification: "Recorded degree", details: ["Recorded distinction"] }],
["certifications", { id: "certification:key-03", name: "Recorded certification", details: ["Recorded detail"] }],
["projects", { id: "project:key-04", name: "Recorded project", bullets: ["Recorded outcome"], skills: ["SQL"] }],
] as const;
test.each(keyedItems)("preserves the server item ID when normalizing %s", (collection, item) => {
const profile = normalizeStructuredCv({ [collection]: [item] });
expect(profile[collection]).toHaveLength(1);
expect(profile[collection][0]).toMatchObject(item);
expect(parseStructuredCvJson(JSON.stringify(profile))[collection][0]).toMatchObject(item);
});
test("legacy structured items without IDs remain usable and acquire no client-generated IDs", () => {
const legacy = Object.fromEntries(keyedItems.map(([collection, item]) => {
const { id: _id, ...fields } = item;
return [collection, [fields]];
}));
const profile = normalizeStructuredCv(legacy);
const serialized = JSON.parse(JSON.stringify(profile));
for (const [collection] of keyedItems) {
expect(profile[collection]).toHaveLength(1);
expect(serialized[collection][0]).toMatchObject(legacy[collection][0]);
expect(serialized[collection][0]).not.toHaveProperty("id");
}
});
test("legacy parsed sections keep their facts without inventing structured item IDs", () => {
const profile = parseStructuredCvJson(JSON.stringify([
{ name: "Professional Summary", content: "Recorded delivery\nRecorded support", wordCount: 4 },
{ name: "Skills", content: "C#, SQL", wordCount: 2 },
{ name: "Languages", content: "Norwegian: Native", wordCount: 2 },
]));
expect(profile.summary).toEqual(["Recorded delivery", "Recorded support"]);
expect(profile.skills).toEqual(["C#", "SQL"]);
expect(profile.languages).toEqual([{ name: "Norwegian", level: "Native", notes: undefined }]);
for (const [collection] of keyedItems) expect(profile[collection]).toEqual([]);
});
test("API null and missing numeric metadata remain absent rather than becoming zero", () => {
const inputs = [
{ metadata: { profileVersion: null, appliedExtractionRunId: null, fields: { "contact.fullName": { confidence: null, sourcePage: null } } } },
{ metadata: { fields: { "contact.fullName": {} } } },
];
for (const input of inputs) {
for (const profile of [normalizeStructuredCv(input), parseStructuredCvJson(JSON.stringify(input))]) {
expect(profile.metadata.profileVersion).toBeUndefined();
expect(profile.metadata.appliedExtractionRunId).toBeUndefined();
expect(profile.metadata.fields["contact.fullName"].confidence).toBeUndefined();
expect(profile.metadata.fields["contact.fullName"].sourcePage).toBeUndefined();
const serialized = JSON.parse(JSON.stringify(profile));
expect(serialized.metadata).not.toHaveProperty("profileVersion");
expect(serialized.metadata).not.toHaveProperty("appliedExtractionRunId");
expect(serialized.metadata.fields["contact.fullName"]).not.toHaveProperty("confidence");
expect(serialized.metadata.fields["contact.fullName"]).not.toHaveProperty("sourcePage");
}
}
});
test("zero, finite numbers and numeric strings keep the established metadata number conversion", () => {
for (const values of [[0, 0, 0, 0], [5, 3, 0.8, 2], ["6", "4", "0.65", "3"]]) {
const [profileVersion, appliedExtractionRunId, confidence, sourcePage] = values;
const input = { metadata: { profileVersion, appliedExtractionRunId, fields: { "contact.fullName": { confidence, sourcePage } } } };
for (const profile of [normalizeStructuredCv(input), parseStructuredCvJson(JSON.stringify(input))]) {
expect(profile.metadata.profileVersion).toBe(Number(profileVersion));
expect(profile.metadata.appliedExtractionRunId).toBe(Number(appliedExtractionRunId));
expect(profile.metadata.fields["contact.fullName"].confidence).toBe(Number(confidence));
expect(profile.metadata.fields["contact.fullName"].sourcePage).toBe(Number(sourcePage));
}
}
});
+12 -4
View File
@@ -34,6 +34,7 @@ export type StructuredCvContact = {
};
export type StructuredCvJob = {
id?: string;
title?: string;
company?: string;
location?: string;
@@ -45,6 +46,7 @@ export type StructuredCvJob = {
};
export type StructuredCvEducation = {
id?: string;
qualification?: string;
qualificationLevel?: string;
institution?: string;
@@ -55,6 +57,7 @@ export type StructuredCvEducation = {
};
export type StructuredCvCertification = {
id?: string;
name?: string;
issuer?: string;
location?: string;
@@ -63,6 +66,7 @@ export type StructuredCvCertification = {
};
export type StructuredCvProject = {
id?: string;
name?: string;
role?: string;
location?: string;
@@ -211,15 +215,15 @@ export function normalizeStructuredCv(value: unknown): StructuredCvProfile {
const normalized: StructuredCvProfile = {
version: normalizeString(source.version) ?? "1",
metadata: {
profileVersion: Number.isFinite(Number(source.metadata?.profileVersion)) ? Number(source.metadata.profileVersion) : undefined,
appliedExtractionRunId: Number.isFinite(Number(source.metadata?.appliedExtractionRunId)) ? Number(source.metadata.appliedExtractionRunId) : undefined,
profileVersion: source.metadata?.profileVersion != null && Number.isFinite(Number(source.metadata.profileVersion)) ? Number(source.metadata.profileVersion) : undefined,
appliedExtractionRunId: source.metadata?.appliedExtractionRunId != null && Number.isFinite(Number(source.metadata.appliedExtractionRunId)) ? Number(source.metadata.appliedExtractionRunId) : undefined,
updatedAtUtc: normalizeString(source.metadata?.updatedAtUtc),
fields: source.metadata?.fields && typeof source.metadata.fields === "object"
? Object.fromEntries(Object.entries(source.metadata.fields as Record<string, any>).map(([key, value]) => [key, {
confidence: Number.isFinite(Number(value?.confidence)) ? Number(value.confidence) : undefined,
confidence: value?.confidence != null && Number.isFinite(Number(value.confidence)) ? Number(value.confidence) : undefined,
method: normalizeString(value?.method),
sourceSnippet: normalizeString(value?.sourceSnippet),
sourcePage: Number.isFinite(Number(value?.sourcePage)) ? Number(value.sourcePage) : undefined,
sourcePage: value?.sourcePage != null && Number.isFinite(Number(value.sourcePage)) ? Number(value.sourcePage) : undefined,
sourceBlockId: normalizeString(value?.sourceBlockId),
reviewState: normalizeString(value?.reviewState),
lastUpdatedAtUtc: normalizeString(value?.lastUpdatedAtUtc),
@@ -240,6 +244,7 @@ export function normalizeStructuredCv(value: unknown): StructuredCvProfile {
summary: normalizeList(source.summary),
jobs: Array.isArray(source.jobs)
? source.jobs.map((job: any) => ({
id: typeof job?.id === "string" ? job.id : undefined,
title: normalizeString(job?.title),
company: normalizeString(job?.company),
location: normalizeString(job?.location),
@@ -252,6 +257,7 @@ export function normalizeStructuredCv(value: unknown): StructuredCvProfile {
: [],
education: Array.isArray(source.education)
? source.education.map((education: any) => ({
id: typeof education?.id === "string" ? education.id : undefined,
qualification: normalizeString(education?.qualification),
qualificationLevel: normalizeString(education?.qualificationLevel),
institution: normalizeString(education?.institution),
@@ -263,6 +269,7 @@ export function normalizeStructuredCv(value: unknown): StructuredCvProfile {
: [],
certifications: Array.isArray(source.certifications)
? source.certifications.map((certification: any) => ({
id: typeof certification?.id === "string" ? certification.id : undefined,
name: normalizeString(certification?.name),
issuer: normalizeString(certification?.issuer),
location: normalizeString(certification?.location),
@@ -272,6 +279,7 @@ export function normalizeStructuredCv(value: unknown): StructuredCvProfile {
: [],
projects: Array.isArray(source.projects)
? source.projects.map((project: any) => ({
id: typeof project?.id === "string" ? project.id : undefined,
name: normalizeString(project?.name),
role: normalizeString(project?.role),
location: normalizeString(project?.location),