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
@@ -0,0 +1,254 @@
import { expect, test as base, type APIRequestContext, type APIResponse } from '@playwright/test';
import { translations } from '../src/i18n/translations';
import type { CvVariant } from '../src/cvBuilder';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
type Owner = { id: string; email: string; headers: Record<string, string> };
type SourceItem = { id?: string; [key: string]: unknown };
type CareerProfile = Record<string, unknown> & {
contact: Record<string, unknown>;
jobs: SourceItem[];
education: SourceItem[];
projects: SourceItem[];
certifications: SourceItem[];
};
type CareerReadback = { profile: CareerProfile; completeness: unknown; cvText: string | null };
type CareerVersion = { version: number; source: string; createdAtUtc: string; isCurrent: boolean };
type Preview = { themeId: string; html: string; suggestedFileName: string };
async function json<T>(response: APIResponse, status = 200): Promise<T> {
expect(response.status(), `${response.url()} must return ${status}`).toBe(status);
return response.json();
}
async function login(api: APIRequestContext, email: string): Promise<Owner> {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`));
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN');
expect(csrf).toBeDefined();
return { id: me.id, email, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext } };
}
async function createOwner(admin: APIRequestContext, headers: Record<string, string>) {
const email = `p7-career-ids-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers, data: { email, password, roles: [] } })).status()).toBe(200);
return email;
}
const test = base.extend<{ owner: Owner }>({
owner: async ({ page, context, playwright }, provide) => {
const admin = await playwright.request.newContext();
try {
const adminOwner = await login(admin, 'e2e@example.test');
const owner = await login(page.request, await createOwner(admin, adminOwner.headers));
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: 'en' } })).status()).toBe(204);
await context.route('**/*', route => {
const url = new URL(route.request().url());
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol)
? route.continue() : route.abort();
});
await context.addInitScript(() => {
localStorage.setItem('uiLanguage', 'en');
localStorage.setItem('jobtracker.themeMode', 'light');
});
await provide(owner);
} finally { await admin.dispose(); }
},
});
function sourceIds(profile: CareerProfile) {
return Object.fromEntries((['jobs', 'education', 'projects', 'certifications'] as const)
.map(key => [key, profile[key].map(item => item.id)]));
}
function historyMetadata(versions: CareerVersion[]) {
return versions.map(({ version, source, createdAtUtc }) => ({ version, source, createdAtUtc }));
}
test('[P7-03 PRES-C01 C03 C06 C07 C10 W05 W06] unrelated canonical UI save preserves four source IDs, existing CV overrides, sibling and frozen package', async ({ page, owner }, info) => {
test.setTimeout(120_000);
const raw = 'Exact owner source — æøå\nReviewed work and qualifications.\n';
const profile = {
version: '1',
metadata: { fields: { 'contact.fullName': { confidence: 0.9, method: 'manual', sourceSnippet: 'Owner-reviewed contact', reviewState: 'reviewed' } } },
contact: { fullName: 'Åse Source-ID', headline: 'Canonical engineer', phone: '+47 900 00 001', location: 'Oslo, Norge',
website: 'https://example.test/portfolio', linkedIn: 'https://www.linkedin.com/in/source-fixture', gitHub: 'https://github.com/source-fixture',
links: [{ label: 'Reviewed work', url: 'https://example.test/work' }] },
summary: ['A source-backed professional summary.'],
jobs: [{ id: 'job-source', title: 'Canonical role', company: 'Nordlys AS', start: 'Jan 2020', end: 'Dec 2022',
bullets: ['Canonical work evidence.'], skills: ['C#'] }],
education: [{ id: 'education-source', qualification: 'Computer science', qualificationLevel: 'Bachelor', institution: 'Fixture University',
location: 'Oslo', start: 'Aug 2016', end: 'Jun 2019', details: ['Reviewed qualification.'] }],
projects: [{ id: 'project-source', name: 'Recorded project', role: 'Developer', location: 'Remote', start: '2023-01', end: '2023-12',
bullets: ['Reviewed project evidence.'], skills: ['React'] }],
certifications: [{ id: 'cert-source', name: 'Recorded certification', issuer: 'Fixture Academy', location: 'Remote', date: 'Jun 2022',
details: ['Reviewed certification detail.'] }],
skills: ['C#', 'Norsk'], languages: [{ name: 'Norwegian Sign Language', level: 'Professional working proficiency', notes: 'Reviewed language note.' }],
interests: ['Documented community work'], awards: ['Reviewed award'], publications: ['Reviewed publication'],
organisations: ['Reviewed organisation'], references: ['Available by owner agreement'],
otherSections: [{ title: 'Custom factual section', items: ['Recorded custom fact.', 'Another reviewed custom fact.'] }],
};
await json<CareerReadback>(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile, cvText: raw } }));
const before = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
expect(sourceIds(before.profile)).toEqual({ jobs: ['job-source'], education: ['education-source'], projects: ['project-source'], certifications: ['cert-source'] });
const company = await json<{ id: number }>(await page.request.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Source-ID fixture AS' } }), 201);
const job = await json<{ id: number }>(await page.request.post(`${apiUrl}/jobapplications`, { headers: owner.headers,
data: { companyId: company.id, jobTitle: 'Source-ID preservation', status: 'Saved' } }), 201);
const retainedTitle = 'CV-only retained role';
const retainedBullet = 'CV-only retained evidence';
let selected = await json<CvVariant>(await page.request.post(`${apiUrl}/cv/variants`, { headers: owner.headers,
data: { name: 'Selected source-ID CV', jobApplicationId: job.id, settings: { themeId: 'minimal',
overrides: { [before.profile.jobs[0].id!]: { title: retainedTitle, bullets: [retainedBullet] } } } } }));
const sibling = await json<CvVariant>(await page.request.post(`${apiUrl}/cv/variants`, { headers: owner.headers,
data: { name: 'Untouched source-ID sibling', settings: { themeId: 'minimal' } } }));
const selectedHistoryBefore = await json<unknown>(await page.request.get(`${apiUrl}/cv/variants/${selected.id}/versions`));
const siblingHistoryBefore = await json<unknown>(await page.request.get(`${apiUrl}/cv/variants/${sibling.id}/versions`));
const selectedPreviewBefore = await json<Preview>(await page.request.get(`${apiUrl}/cv/variants/${selected.id}/preview`));
const siblingPreviewBefore = await json<Preview>(await page.request.get(`${apiUrl}/cv/variants/${sibling.id}/preview`));
expect(selectedPreviewBefore.html).toContain(retainedTitle);
expect(selectedPreviewBefore.html).toContain(retainedBullet);
expect(siblingPreviewBefore.html).toContain('Canonical role');
expect((await page.request.put(`${apiUrl}/jobapplications/${job.id}/cv`, { headers: owner.headers, data: { variantId: selected.id } })).status()).toBe(200);
const packageSummary = await json<{ id: number }>(await page.request.post(`${apiUrl}/jobapplications/${job.id}/submitted-packages`, { headers: owner.headers }));
const packageUrl = `${apiUrl}/jobapplications/${job.id}/submitted-packages/${packageSummary.id}`;
const frozenBefore = await json<unknown>(await page.request.get(packageUrl));
// Linking the selected CV updates its timestamp during setup. The preservation
// baseline must include that setup mutation before the unrelated career edit.
selected = await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${selected.id}`));
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/career?section=profile');
const t = translations.en;
const phone = page.getByLabel(t.profileCvContactPhone, { exact: true });
await expect(phone).toHaveValue(String(before.profile.contact.phone));
await phone.fill('+47 900 00 002');
const requestPromise = page.waitForRequest(request => request.url() === `${apiUrl}/career/profile` && request.method() === 'PUT');
const responsePromise = page.waitForResponse(response => response.url() === `${apiUrl}/career/profile` && response.request().method() === 'PUT');
await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).focus();
await page.keyboard.press('Enter');
const savedRequest = await requestPromise;
const savedResponse = await responsePromise;
const savedStatus = savedResponse.status();
const requested = savedRequest.postDataJSON() as { profile: CareerProfile; cvText: string };
const after = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
const selectedAfter = await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${selected.id}`));
const siblingAfter = await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${sibling.id}`));
const selectedHistoryAfter = await json<unknown>(await page.request.get(`${apiUrl}/cv/variants/${selected.id}/versions`));
const siblingHistoryAfter = await json<unknown>(await page.request.get(`${apiUrl}/cv/variants/${sibling.id}/versions`));
const selectedPreviewAfter = await json<Preview>(await page.request.get(`${apiUrl}/cv/variants/${selected.id}/preview`));
const siblingPreviewAfter = await json<Preview>(await page.request.get(`${apiUrl}/cv/variants/${sibling.id}/preview`));
const frozenAfter = await json<unknown>(await page.request.get(packageUrl));
// Collect the persisted IDs AND effective override output before the first preservation
// assertion, so the RED attempt retains both consequences of the existing browser save.
await info.attach('native-career-source-id-readbacks', { body: JSON.stringify({ savedStatus, requested,
ids: { before: sourceIds(before.profile), requested: sourceIds(requested.profile), after: sourceIds(after.profile) },
observed: {
requestedIdsEqual: JSON.stringify(sourceIds(requested.profile)) === JSON.stringify(sourceIds(before.profile)),
persistedIdsEqual: JSON.stringify(sourceIds(after.profile)) === JSON.stringify(sourceIds(before.profile)),
overrideTitleBefore: selectedPreviewBefore.html.includes(retainedTitle), overrideTitleAfter: selectedPreviewAfter.html.includes(retainedTitle),
overrideBulletBefore: selectedPreviewBefore.html.includes(retainedBullet), overrideBulletAfter: selectedPreviewAfter.html.includes(retainedBullet),
selectedDtoUnchanged: JSON.stringify(selectedAfter) === JSON.stringify(selected), siblingDtoUnchanged: JSON.stringify(siblingAfter) === JSON.stringify(sibling),
selectedHistoryUnchanged: JSON.stringify(selectedHistoryAfter) === JSON.stringify(selectedHistoryBefore),
siblingHistoryUnchanged: JSON.stringify(siblingHistoryAfter) === JSON.stringify(siblingHistoryBefore),
frozenDetailUnchanged: JSON.stringify(frozenAfter) === JSON.stringify(frozenBefore),
},
canonical: { before, after }, selected: { before: selected, after: selectedAfter, historyBefore: selectedHistoryBefore, historyAfter: selectedHistoryAfter,
previewBefore: selectedPreviewBefore, previewAfter: selectedPreviewAfter },
sibling: { before: sibling, after: siblingAfter, historyBefore: siblingHistoryBefore, historyAfter: siblingHistoryAfter,
previewBefore: siblingPreviewBefore, previewAfter: siblingPreviewAfter }, frozen: { before: frozenBefore, after: frozenAfter },
qualification: 'Actual canonical browser Save and local SQLite HTTP readbacks. No PDF/model/provider call; frozen full-detail/HTML proof only, no new attachment-byte proof or concurrency-protection claim.' }), contentType: 'application/json' });
await info.attach('native-career-source-id-editor', { body: await page.screenshot({ fullPage: true }), contentType: 'image/png' });
expect(savedStatus).toBe(200);
expect(sourceIds(requested.profile)).toEqual(sourceIds(before.profile));
expect(sourceIds(after.profile)).toEqual(sourceIds(before.profile));
const { contact: beforeContact, ...nonContactBefore } = before.profile;
const { contact: afterContact, ...nonContactAfter } = after.profile;
expect(nonContactAfter).toEqual(nonContactBefore);
expect(afterContact).toEqual({ ...beforeContact, phone: '+47 900 00 002' });
expect(requested.cvText).toBe(raw);
expect(after.cvText).toBe(raw);
expect(selectedAfter).toEqual(selected);
expect(siblingAfter).toEqual(sibling);
expect(selectedHistoryAfter).toEqual(selectedHistoryBefore);
expect(siblingHistoryAfter).toEqual(siblingHistoryBefore);
expect(selectedPreviewAfter.html).toContain(retainedTitle);
expect(selectedPreviewAfter.html).toContain(retainedBullet);
expect(siblingPreviewAfter.html).toContain('Canonical role');
expect(siblingPreviewAfter.html).not.toContain(retainedTitle);
expect(frozenAfter).toEqual(frozenBefore);
await page.reload();
await expect(page.getByLabel(t.profileCvContactPhone, { exact: true })).toHaveValue('+47 900 00 002');
});
test('[P7-03 PRES-C01 C03 E05 E09] real HTTP bounds canonical last-writer saves and structured-history recovery without claiming stale-write protection', async ({ page, owner, playwright }, info) => {
test.setTimeout(90_000);
const initialProfile = { version: '1', contact: { fullName: 'Canonical recovery owner', headline: 'Initial source' },
jobs: [{ id: 'recovery-source', title: 'Recorded role', company: 'Fixture AS', bullets: ['Recorded evidence.'] }], skills: ['Norsk'] };
const initialRaw = 'Initial raw source — æøå\n';
await json<CareerReadback>(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile: initialProfile, cvText: initialRaw } }));
const staleSnapshot = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
const initialHistory = await json<CareerVersion[]>(await page.request.get(`${apiUrl}/career/profile/versions`));
expect(initialHistory.map(row => row.version)).toEqual([1]);
const secondSession = await playwright.request.newContext();
const admin = await playwright.request.newContext();
const other = await playwright.request.newContext();
const anonymous = await playwright.request.newContext();
try {
const secondOwner = await login(secondSession, owner.email);
expect(secondOwner.id).toBe(owner.id);
const secondSnapshot = await json<CareerReadback>(await secondSession.get(`${apiUrl}/career/profile`));
expect(secondSnapshot).toEqual(staleSnapshot);
const remoteRaw = 'Remote current raw source — æøå\n';
const remote = await json<CareerReadback>(await secondSession.put(`${apiUrl}/career/profile`, { headers: secondOwner.headers,
data: { profile: { ...secondSnapshot.profile, contact: { ...secondSnapshot.profile.contact, headline: 'Remote reviewed source' } }, cvText: remoteRaw } }));
const remoteHistory = await json<CareerVersion[]>(await page.request.get(`${apiUrl}/career/profile/versions`));
expect(remoteHistory.map(row => row.version)).toEqual([2, 1]);
const currentRaw = 'Last writer raw source remains current after structured restore — æøå\n';
const staleBody = { profile: { ...staleSnapshot.profile, contact: { ...staleSnapshot.profile.contact, headline: 'Stale snapshot accepted as last writer' } }, cvText: currentRaw };
const staleWrite = await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: staleBody });
const staleStatus = staleWrite.status();
const lastWriter = await json<CareerReadback>(staleWrite);
const afterStale = await json<CareerReadback>(await secondSession.get(`${apiUrl}/career/profile`));
const historyAfterStale = await json<CareerVersion[]>(await secondSession.get(`${apiUrl}/career/profile/versions`));
expect(staleStatus).toBe(200);
expect(staleBody).not.toHaveProperty('expectedVersion');
expect(afterStale).toEqual(lastWriter);
expect(afterStale.profile.contact.headline).toBe('Stale snapshot accepted as last writer');
expect(historyAfterStale.map(row => row.version)).toEqual([3, 2, 1]);
expect(historyMetadata(historyAfterStale.slice(1))).toEqual(historyMetadata(remoteHistory));
const restored = await json<CareerReadback>(await secondSession.post(`${apiUrl}/career/profile/versions/2/restore`, { headers: secondOwner.headers }));
const recovered = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
const restoredHistory = await json<CareerVersion[]>(await page.request.get(`${apiUrl}/career/profile/versions`));
expect(restored.profile).toEqual(remote.profile);
expect(recovered).toEqual(restored);
expect(recovered.cvText).toBe(currentRaw);
expect(recovered.cvText).not.toBe(remoteRaw);
expect(restoredHistory.map(row => row.version)).toEqual([4, 3, 2, 1]);
expect(restoredHistory[0]).toMatchObject({ source: 'restore:v2', isCurrent: true });
expect(restoredHistory.slice(1).every(row => !row.isCurrent)).toBe(true);
expect(historyMetadata(restoredHistory.slice(1))).toEqual(historyMetadata(historyAfterStale));
expect((await page.request.post(`${apiUrl}/career/profile/versions/99999/restore`, { headers: owner.headers })).status()).toBe(404);
const adminOwner = await login(admin, 'e2e@example.test');
const otherOwner = await login(other, await createOwner(admin, adminOwner.headers));
const otherBefore = await json<CareerReadback>(await other.get(`${apiUrl}/career/profile`));
expect(await json<CareerReadback>(await other.get(`${apiUrl}/career/profile?ownerUserId=${encodeURIComponent(owner.id)}`))).toEqual(otherBefore);
expect(await json<CareerVersion[]>(await other.get(`${apiUrl}/career/profile/versions?ownerUserId=${encodeURIComponent(owner.id)}`))).toEqual([]);
expect((await other.post(`${apiUrl}/career/profile/versions/2/restore`, { headers: otherOwner.headers })).status()).toBe(404);
await json<CareerReadback>(await other.put(`${apiUrl}/career/profile`, { headers: otherOwner.headers,
data: { ownerUserId: owner.id, profile: { version: '1', contact: { fullName: 'Other account only' }, skills: ['Other owner fact'] }, cvText: 'Other owner raw source' } }));
expect((await json<CareerReadback>(await other.get(`${apiUrl}/career/profile`))).profile.contact.fullName).toBe('Other account only');
expect((await anonymous.get(`${apiUrl}/career/profile`)).status()).toBe(401);
expect((await anonymous.get(`${apiUrl}/career/profile/versions`)).status()).toBe(401);
expect((await anonymous.put(`${apiUrl}/career/profile`, { data: staleBody })).status()).toBe(401);
expect((await anonymous.post(`${apiUrl}/career/profile/versions/2/restore`)).status()).toBe(401);
expect(await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`))).toEqual(recovered);
expect(await json<CareerVersion[]>(await page.request.get(`${apiUrl}/career/profile/versions`))).toEqual(restoredHistory);
await info.attach('native-canonical-stale-contract-characterization', { body: JSON.stringify({ initial: staleSnapshot, remote,
staleRequest: staleBody, staleStatus, lastWriter: afterStale, historyAfterStale, recovered, restoredHistory,
ownerIsolation: { forgedOwnerQueryCannotReadForeign: true, foreignStructuredVersion404: true, forgedBodyWritesOnlyOwnProfile: true, anonymous401: true },
qualification: 'Two real authenticated HTTP sessions in disposable SQLite. Canonical stale saves remain accepted last-writer replacements; history recovery appends a structured revision and does not restore raw cvText. No conflict prevention, browser draft-recovery, attachment-byte, model/provider or production acceptance is claimed.' }), contentType: 'application/json' });
} finally { await secondSession.dispose(); await admin.dispose(); await other.dispose(); await anonymous.dispose(); }
});
+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),