fix(career): preserve source ids and absent metadata
This commit is contained in:
@@ -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' } });
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user