diff --git a/config/site.preview.json b/config/site.preview.json new file mode 100644 index 0000000..94fa097 --- /dev/null +++ b/config/site.preview.json @@ -0,0 +1,5 @@ +{ + "environment": "preview", + "canonicalUrl": "https://portfolio.localhost/", + "buildId": "preview-semantic-v1" +} diff --git a/content/public/asset-manifest.json b/content/public/asset-manifest.json new file mode 100644 index 0000000..49bdd93 --- /dev/null +++ b/content/public/asset-manifest.json @@ -0,0 +1,3 @@ +{ + "assets": [] +} diff --git a/content/public/portfolio-content.json b/content/public/portfolio-content.json new file mode 100644 index 0000000..67aa54c --- /dev/null +++ b/content/public/portfolio-content.json @@ -0,0 +1,245 @@ +{ + "identity": { + "name": "Luca Pîslaru", + "role": "System Administrator & Full-Stack Developer", + "valueProposition": "Building and operating reliable systems and software." + }, + "navigation": [ + { "id": "start", "label": "Start" }, + { "id": "profile", "label": "Profile" }, + { "id": "experience", "label": "Experience" }, + { "id": "infrastructure", "label": "Infrastructure" }, + { "id": "selected-builds", "label": "Selected builds" }, + { "id": "additional-work", "label": "Additional work" }, + { "id": "toolkit", "label": "Toolkit" }, + { "id": "contact", "label": "Contact" } + ], + "profile": { + "introduction": "I work across Linux infrastructure and full-stack delivery, pairing practical operations with software built to be understood, maintained, and improved over time.", + "screeningFacts": [ + { "label": "Education", "value": "Bachelor's degree in Business Management IT, Romanian-American University, 2023–2026" }, + { "label": "Languages", "value": "Romanian (native), English (C2), French (A1)" }, + { "label": "Certification", "value": "PCEP — Certified Entry-Level Python Programmer, 2022" } + ] + }, + "experience": [ + { + "id": "mihai-razus-pfa", + "role": "Full-Stack Developer & DevOps collaborator", + "organization": "Mihai Razus PFA", + "dates": "Aug 2025 – Present", + "highlights": [ + "Contribute to full-stack delivery across application and deployment work.", + "Support practical DevOps collaboration alongside software development." + ] + }, + { + "id": "camera-consultantilor-fiscali", + "role": "Green Innovation Intern", + "organization": "Camera Consultanților Fiscali", + "dates": "Jun 2025 – Aug 2025", + "highlights": [ + "Contributed to green innovation work in an internship setting.", + "Applied structured problem-solving and digital skills to assigned work." + ] + }, + { + "id": "isense-solutions", + "role": "Data Science Intern", + "organization": "iSense Solutions", + "dates": "Jun 2025 – Aug 2025", + "highlights": [ + "Contributed to data science work in an internship setting.", + "Applied analytical and programming skills to assigned tasks." + ] + } + ], + "systemsOperations": { + "id": "professional-systems-operations", + "title": "Professional systems operations", + "summary": "Professional work that connects application delivery with the Linux environments and deployment practices that support it.", + "evidence": [ + "Linux server administration and service management.", + "Docker and Nginx deployment work, including TLS-aware service delivery.", + "Operational troubleshooting alongside full-stack development." + ] + }, + "infrastructure": { + "id": "self-hosted-platform-lifecycle", + "title": "Secure self-hosted platform and lifecycle", + "summary": "A personal Linux platform operated as a compact systems case study: secure ingress, deliberate access controls, service lifecycle discipline, and routine maintenance.", + "bands": [ + { + "id": "ingress-and-tls", + "title": "Ingress and TLS", + "summary": "Routes services through Nginx with certificate issuance and renewal managed through Let's Encrypt and Certbot; exposed entry points receive fail2ban protection." + }, + { + "id": "identity-and-access", + "title": "Identity and access", + "summary": "Uses an authentication layer for protected services, with passkeys, two-factor authentication, and recovery considered without publishing policy details." + }, + { + "id": "service-lifecycle", + "title": "Service lifecycle", + "summary": "Deploys containerized services, manages updates, and treats service enrollment and retirement as part of ongoing maintenance." + }, + { + "id": "operations-and-maintenance", + "title": "Operations", + "summary": "Investigates service issues and performs routine maintenance while keeping the platform understandable and manageable." + } + ] + }, + "projects": { + "selected": [ + { + "id": "private-medication-tracker", + "title": "Private self-hosted medication tracker", + "visibility": "private", + "maturity": "active", + "summary": "A private multi-device medication system for schedules, stock awareness, adherence logging, and shared administration.", + "problem": "Medication routines need a dependable way to handle scheduled and as-needed entries across devices.", + "contribution": "Designed and built the private client, server, synchronization, and administrative workflow.", + "technicalDecisions": [ + "Supports profiles, scheduled and as-needed medication, stock tracking, notifications, and logged activity.", + "Keeps a server component and administrative interface alongside the multi-device experience." + ], + "outcome": "The product remains private; this portfolio presents its scope without a public link or media.", + "assetIds": [] + }, + { + "id": "footy-tracker", + "title": "Footy Tracker", + "visibility": "public", + "maturity": "work-in-progress", + "summary": "A work-in-progress computer-vision pipeline for football footage.", + "problem": "Football footage needs a repeatable way to detect and follow players over time.", + "contribution": "Built the player detection and tracking pipeline and continue to explore jersey recognition.", + "technicalDecisions": [ + "Separates player detection and tracking from the ongoing jersey-recognition work.", + "Keeps the pipeline focused on footage analysis rather than claiming production readiness." + ], + "outcome": "Player detection and tracking are the current focus; jersey recognition remains ongoing work.", + "assetIds": [] + }, + { + "id": "opinion-dynamics-simulation", + "title": "Opinion Dynamics Simulation", + "visibility": "public", + "maturity": "experimental", + "summary": "An experimental agent-based simulation for exploring how group opinions change.", + "problem": "Explore how local interactions can produce different group-level opinion patterns.", + "contribution": "Built the experimental model and the result views used to inspect its runs.", + "technicalDecisions": [ + "Treats the work as an experimental model rather than a predictive product.", + "Uses structured result views to compare changing variables and observed trends." + ], + "outcome": "The project is presented as research exploration, with conclusions limited to the modeled scenarios.", + "assetIds": [] + } + ], + "additional": [ + { + "id": "private-game-prototype", + "title": "Private game prototype", + "visibility": "private", + "maturity": "prototype", + "summary": "A private three-week prototype focused on testing a compact gameplay loop.", + "problem": "Test a game concept quickly while keeping the scope deliberately small.", + "contribution": "Built and iterated on the prototype during a three-week development window.", + "technicalDecisions": [ + "Focused the work on a short gameplay loop rather than a full production release." + ], + "outcome": "The prototype remains private and is described without a public link or media.", + "assetIds": [] + }, + { + "id": "private-healthcare-platform", + "title": "Private healthcare platform", + "visibility": "private", + "maturity": "active", + "summary": "A private web platform presented here only at a high level.", + "problem": "A healthcare workflow needed a focused web-based interface.", + "contribution": "Contributed to the private platform's web experience.", + "technicalDecisions": [ + "Keeps product details and any identifying interface data outside the public portfolio." + ], + "outcome": "The platform remains private and has no public link or media.", + "assetIds": [] + }, + { + "id": "currency-converter", + "title": "Currency Converter", + "visibility": "public", + "maturity": "learning", + "summary": "A modest .NET and Avalonia learning project.", + "problem": "Practice a focused desktop workflow for currency conversion.", + "contribution": "Implemented the application as a scoped learning exercise.", + "technicalDecisions": [ + "Uses .NET and Avalonia to explore a typed desktop interface." + ], + "outcome": "A deliberately modest project for developing desktop application fundamentals.", + "assetIds": [] + }, + { + "id": "linux-modding-guide", + "title": "Linux Modding Guide", + "visibility": "public", + "maturity": "active", + "summary": "Community documentation that shares Linux troubleshooting and technical writing practice.", + "problem": "Useful Linux troubleshooting knowledge benefits from clear, reusable documentation.", + "contribution": "Wrote and maintained community-facing technical guidance.", + "technicalDecisions": [ + "Favors practical explanations that help readers understand and resolve issues." + ], + "outcome": "An ongoing example of technical writing without relying on popularity counts.", + "assetIds": [] + } + ] + }, + "toolkit": [ + { + "title": "Systems & delivery", + "technologies": [ + { "name": "Linux" }, + { "name": "Docker" }, + { "name": "Nginx" }, + { "name": "Proxmox / KVM" }, + { "name": "Git" } + ] + }, + { + "title": "Software & data", + "technologies": [ + { "name": "Python" }, + { "name": "Vue.js" }, + { "name": "JavaScript" }, + { "name": "C# / .NET" }, + { "name": "Flutter / Dart" }, + { "name": "PostgreSQL / SQL" }, + { "name": "Headless WordPress" } + ] + }, + { + "title": "Additional experience", + "technologies": [ + { "name": "Java" } + ] + } + ], + "contact": { + "email": "luca.pislaru@gmail.com", + "social": [ + { "service": "GitHub", "url": "https://github.com/LucaPisl" }, + { "service": "GitLab", "url": "https://gitlab.com/luca_pisl" }, + { "service": "LinkedIn", "url": "https://linkedin.com/in/luca-pisl" } + ], + "cvMessage": "For a current CV or role-specific information, contact me directly." + }, + "metadata": { + "title": "Luca Pîslaru — System Administrator & Full-Stack Developer", + "description": "Portfolio of Luca Pîslaru, a System Administrator & Full-Stack Developer building and operating reliable systems and software." + }, + "sceneDescription": "A restrained retro-futuristic night drive supports the reading journey while the portfolio content remains fully available as a semantic document." +} diff --git a/docs/content-approval-checklist.md b/docs/content-approval-checklist.md new file mode 100644 index 0000000..12d7933 --- /dev/null +++ b/docs/content-approval-checklist.md @@ -0,0 +1,50 @@ +# Public content checklist + +This checklist records the Phase 1 public projection. Every item is either included as approved public content or omitted from this projection. + +| Area | Public item | Status | +| --- | --- | --- | +| Identity | Luca Pîslaru | approved | +| Identity | System Administrator & Full-Stack Developer | approved | +| Identity | Building and operating reliable systems and software | approved | +| Profile | Education | approved | +| Profile | Languages | approved | +| Profile | PCEP — Certified Entry-Level Python Programmer | approved | +| Experience | Full-Stack Developer & DevOps collaborator at Mihai Razus PFA | approved | +| Experience | Green Innovation Intern at Camera Consultanților Fiscali | approved | +| Experience | Data Science Intern at iSense Solutions | approved | +| Systems operations | Professional systems operations | approved | +| Infrastructure | Ingress and TLS | approved | +| Infrastructure | Identity and access | approved | +| Infrastructure | Service lifecycle | approved | +| Infrastructure | Operations | approved | +| Selected build | Private self-hosted medication tracker | approved | +| Selected build | Footy Tracker | approved | +| Selected build | Opinion Dynamics Simulation | approved | +| Additional work | Private game prototype | approved | +| Additional work | Private healthcare platform | approved | +| Additional work | Currency Converter | approved | +| Additional work | Linux Modding Guide | approved | +| Toolkit | Linux | approved | +| Toolkit | Docker | approved | +| Toolkit | Nginx | approved | +| Toolkit | Proxmox / KVM | approved | +| Toolkit | Git | approved | +| Toolkit | Python | approved | +| Toolkit | Vue.js | approved | +| Toolkit | JavaScript | approved | +| Toolkit | C# / .NET | approved | +| Toolkit | Flutter / Dart | approved | +| Toolkit | PostgreSQL / SQL | approved | +| Toolkit | Headless WordPress | approved | +| Toolkit | Java | approved | +| Contact | luca.pislaru@gmail.com | approved | +| Contact | GitHub | approved | +| Contact | GitLab | approved | +| Contact | LinkedIn | approved | +| Contact | Current or role-specific CV by direct contact | approved | +| Metadata | Page title | approved | +| Metadata | Page description | approved | +| Metadata | Social preview asset | omit | +| Assets | Public asset records | omit | +| Assets | Project, toolkit, and metadata asset references | omit | diff --git a/package.json b/package.json index 7c371eb..3269533 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "build:client": "vite build --outDir dist/client --manifest", "build:ssr": "vite build --ssr src/entry-server.ts --outDir dist-ssr", "prerender": "node scripts/prerender.mjs", - "build:preview": "npm run typecheck && npm run test:unit && npm run build:client && npm run build:ssr && npm run prerender -- --config config/site.preview.json" + "prepare:preview-assets": "node scripts/materialize-empty-asset-set.mjs", + "build:preview": "npm run prepare:preview-assets && npm run typecheck && npm run test:unit && npm run build:client && npm run build:ssr && npm run prerender -- --config config/site.preview.json" }, "keywords": [], "author": "", diff --git a/scripts/materialize-empty-asset-set.mjs b/scripts/materialize-empty-asset-set.mjs new file mode 100644 index 0000000..3ee061c --- /dev/null +++ b/scripts/materialize-empty-asset-set.mjs @@ -0,0 +1,33 @@ +import { lstat, mkdir, readFile, readdir } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const manifestPath = resolve(repositoryRoot, 'content/public/asset-manifest.json'); +const preparedAssetsPath = resolve(repositoryRoot, 'inputs/prepared-assets'); + +const readEmptyManifest = async () => { + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + if (Object.keys(manifest).length !== 1 || !Array.isArray(manifest.assets) || manifest.assets.length !== 0) { + throw new Error('Phase 1 asset manifest must contain exactly zero assets.'); + } +}; + +const ensureEmptyPreparedAssetDirectory = async () => { + try { + const stats = await lstat(preparedAssetsPath); + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error('Prepared asset path must be a non-symlink directory.'); + } + } catch (error) { + if ((error).code !== 'ENOENT') throw error; + await mkdir(preparedAssetsPath, { recursive: true }); + } + + if ((await readdir(preparedAssetsPath)).length !== 0) { + throw new Error('Prepared asset directory must be empty during Phase 1.'); + } +}; + +await readEmptyManifest(); +await ensureEmptyPreparedAssetDirectory(); diff --git a/src/content/schemas.ts b/src/content/schemas.ts index 36f9198..0596409 100644 --- a/src/content/schemas.ts +++ b/src/content/schemas.ts @@ -9,6 +9,11 @@ const socialHosts = { GitLab: 'gitlab.com', LinkedIn: 'linkedin.com', } as const; +const approvedPrivateProjectTitles = [ + 'Private self-hosted medication tracker', + 'Private healthcare platform', + 'Private game prototype', +] as const; const hasUnpairedSurrogate = (value: string): boolean => { for (let index = 0; index < value.length; index += 1) { @@ -69,8 +74,8 @@ const projectSchema = z.object({ if (project.visibility === 'private' && project.url !== undefined) { context.addIssue({ code: 'custom', message: 'A private project may not include a url.', path: ['url'] }); } - if (project.visibility === 'private' && project.title !== 'Private project') { - context.addIssue({ code: 'custom', message: 'Private project title must be generic.', path: ['title'] }); + if (project.visibility === 'private' && !approvedPrivateProjectTitles.includes(project.title as (typeof approvedPrivateProjectTitles)[number])) { + context.addIssue({ code: 'custom', message: 'Private project title must use an approved generic title.', path: ['title'] }); } }); @@ -111,7 +116,7 @@ const portfolioContentSchema = z.object({ }).strict(), toolkit: z.array(z.object({ title: visibleString, - technologies: z.array(z.object({ name: visibleString, assetId: opaqueId }).strict()).min(1), + technologies: z.array(z.object({ name: visibleString, assetId: opaqueId.optional() }).strict()).min(1), }).strict()).min(1), contact: z.object({ email: z.email(), @@ -131,7 +136,7 @@ const portfolioContentSchema = z.object({ metadata: z.object({ title: visibleString, description: visibleString, - socialPreviewAssetId: opaqueId, + socialPreviewAssetId: opaqueId.optional(), }).strict(), sceneDescription: visibleString, }).strict().superRefine((content, context) => { @@ -192,7 +197,7 @@ const publicAssetSchema = z.object({ context.addIssue({ code: 'custom', message: 'Video assets require dimensions and duration.', path: ['durationMs'] }); } }); -const publicAssetManifestSchema = z.object({ assets: z.array(publicAssetSchema).min(1) }).strict().superRefine((manifest, context) => { +const publicAssetManifestSchema = z.object({ assets: z.array(publicAssetSchema) }).strict().superRefine((manifest, context) => { const ids = manifest.assets.map((asset) => asset.id); if (new Set(ids).size !== ids.length) { context.addIssue({ code: 'custom', message: 'Duplicate asset ID in manifest.', path: ['assets'] }); @@ -233,8 +238,9 @@ export const validateProductionInputs = ( const referencedAssetIds = [ ...content.projects.selected.flatMap((project) => project.assetIds), ...content.projects.additional.flatMap((project) => project.assetIds), - ...content.toolkit.flatMap((group) => group.technologies.map((technology) => technology.assetId)), - content.metadata.socialPreviewAssetId, + ...content.toolkit.flatMap((group) => group.technologies.flatMap((technology) => + technology.assetId === undefined ? [] : [technology.assetId])), + ...(content.metadata.socialPreviewAssetId === undefined ? [] : [content.metadata.socialPreviewAssetId]), ]; for (const assetId of referencedAssetIds) { if (!manifestIds.has(assetId)) { diff --git a/src/content/types.ts b/src/content/types.ts index c197f50..788cfbd 100644 --- a/src/content/types.ts +++ b/src/content/types.ts @@ -56,7 +56,7 @@ export interface PublicProject { export interface ToolkitTechnology { name: string; - assetId: string; + assetId?: string; } export interface ToolkitGroup { @@ -78,7 +78,7 @@ export interface ContactContent { export interface MetadataContent { title: string; description: string; - socialPreviewAssetId: string; + socialPreviewAssetId?: string; } export interface PortfolioContent { diff --git a/tests/fixtures/portfolio-content.valid.json b/tests/fixtures/portfolio-content.valid.json index d6c30a3..7128383 100644 --- a/tests/fixtures/portfolio-content.valid.json +++ b/tests/fixtures/portfolio-content.valid.json @@ -47,7 +47,7 @@ "selected": [ { "id": "private-build-one", - "title": "Private project", + "title": "Private self-hosted medication tracker", "visibility": "private", "maturity": "active", "summary": "A private multi-device application.", diff --git a/tests/integration/public-content.test.ts b/tests/integration/public-content.test.ts new file mode 100644 index 0000000..41258ec --- /dev/null +++ b/tests/integration/public-content.test.ts @@ -0,0 +1,64 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { promisify } from 'node:util'; +import { afterEach, describe, expect, it } from 'vitest'; +import { parsePortfolioContent, parsePublicAssetManifest, parseSiteConfig } from '@/content/schemas'; + +const execFile = promisify(execFileCallback); +const repositoryRoot = process.cwd(); +const readJson = async (relativePath: string): Promise => + JSON.parse(await readFile(`${repositoryRoot}/${relativePath}`, 'utf8')) as unknown; +const preparedAssetsPath = `${repositoryRoot}/inputs/prepared-assets`; + +afterEach(async () => { + await rm(preparedAssetsPath, { recursive: true, force: true }); +}); + +describe('approved public portfolio projection', () => { + it('contains approved identity and balanced evidence without prohibited surfaces', async () => { + const content = parsePortfolioContent(await readJson('content/public/portfolio-content.json')); + const manifest = parsePublicAssetManifest(await readJson('content/public/asset-manifest.json')); + const site = parseSiteConfig(await readJson('config/site.preview.json')); + + expect(content.identity).toEqual(expect.objectContaining({ + name: 'Luca Pîslaru', + role: 'System Administrator & Full-Stack Developer', + })); + expect(content.systemsOperations.title).toBe('Professional systems operations'); + expect(content.infrastructure.title).toBe('Secure self-hosted platform and lifecycle'); + expect(content.projects.selected).toHaveLength(3); + expect(content.projects.selected.filter((project) => project.visibility === 'private') + .every((project) => project.url === undefined)).toBe(true); + expect(content.projects.selected.find((project) => project.visibility === 'private')?.title) + .toBe('Private self-hosted medication tracker'); + expect(content.projects.additional.find((project) => project.id === 'private-game-prototype')?.title) + .toBe('Private game prototype'); + expect([...content.projects.selected, ...content.projects.additional] + .every((project) => project.assetIds.length === 0)).toBe(true); + expect(content.toolkit.flatMap((group) => group.technologies).map((technology) => technology.name)) + .toEqual(expect.arrayContaining(['Linux', 'Docker', 'Python', 'Vue.js', 'Java'])); + expect(content.contact).toEqual(expect.objectContaining({ email: 'luca.pislaru@gmail.com' })); + expect(content.contact.social.map((link) => link.url)).toEqual([ + 'https://github.com/LucaPisl', + 'https://gitlab.com/luca_pisl', + 'https://linkedin.com/in/luca-pisl', + ]); + expect(manifest.assets).toEqual([]); + expect(site).toEqual({ + environment: 'preview', + canonicalUrl: 'https://portfolio.localhost/', + buildId: 'preview-semantic-v1', + }); + expect(JSON.stringify(content)).not.toMatch(/Arch Linux|CV download|heatmap|expert|percent/i); + }); + + it('materializes an empty prepared-asset directory and rejects unexpected members', async () => { + await execFile(process.execPath, ['scripts/materialize-empty-asset-set.mjs'], { cwd: repositoryRoot }); + expect(await readdir(preparedAssetsPath)).toEqual([]); + + await mkdir(preparedAssetsPath, { recursive: true }); + await writeFile(`${preparedAssetsPath}/unexpected.webp`, 'not an asset'); + await expect(execFile(process.execPath, ['scripts/materialize-empty-asset-set.mjs'], { cwd: repositoryRoot })) + .rejects.toThrow(/must be empty/i); + }); +}); diff --git a/tests/unit/content-schemas.test.ts b/tests/unit/content-schemas.test.ts index 09ef634..53211c8 100644 --- a/tests/unit/content-schemas.test.ts +++ b/tests/unit/content-schemas.test.ts @@ -20,7 +20,10 @@ type MutableContentFixture = { contact: { social: Array<{ service: string; url: string }> }; projects: { selected: Array<{ visibility: string; url?: string; assetIds: string[]; title: string }>; + additional: Array<{ assetIds: string[] }>; }; + toolkit: Array<{ technologies: Array<{ assetId?: string }> }>; + metadata: { socialPreviewAssetId?: string }; }; const cloneContent = (): MutableContentFixture => structuredClone(validContent) as MutableContentFixture; @@ -32,6 +35,45 @@ describe('content schemas', () => { expect(parseSiteConfig(validSite).environment).toBe('preview'); }); + it('accepts an empty manifest before Phase 3 assets are prepared', () => { + expect(parsePublicAssetManifest({ assets: [] })).toEqual({ assets: [] }); + }); + + it('accepts the approved generic private project titles', () => { + for (const title of [ + 'Private self-hosted medication tracker', + 'Private healthcare platform', + 'Private game prototype', + ]) { + const content = cloneContent(); + content.projects.selected[0]!.title = title; + expect(() => parsePortfolioContent(content)).not.toThrow(); + } + }); + + it('permits omitted Phase 3 asset references while retaining supplied-reference checks', () => { + const content = cloneContent(); + content.projects.selected[0]!.title = 'Private self-hosted medication tracker'; + for (const project of [...content.projects.selected, ...content.projects.additional]) project.assetIds = []; + delete content.toolkit[0]!.technologies[0]!.assetId; + delete content.metadata.socialPreviewAssetId; + expect(() => validateProductionInputs(content, { assets: [] }, validSite, new URL('file:///prepared/'))).not.toThrow(); + }); + + it('rejects supplied toolkit and metadata asset references absent from the manifest', () => { + const content = cloneContent(); + content.projects.selected[0]!.title = 'Private self-hosted medication tracker'; + for (const project of [...content.projects.selected, ...content.projects.additional]) project.assetIds = []; + content.toolkit[0]!.technologies[0]!.assetId = 'missing-toolkit-asset'; + expect(() => validateProductionInputs(content, { assets: [] }, validSite, new URL('file:///prepared/'))) + .toThrow(/unmanifested asset reference: missing-toolkit-asset/i); + + delete content.toolkit[0]!.technologies[0]!.assetId; + content.metadata.socialPreviewAssetId = 'missing-social-preview'; + expect(() => validateProductionInputs(content, { assets: [] }, validSite, new URL('file:///prepared/'))) + .toThrow(/unmanifested asset reference: missing-social-preview/i); + }); + it('rejects a public link on a private project', () => { const input = cloneContent(); input.projects.selected[0]!.visibility = 'private';