feat: define closed portfolio input contract
This commit is contained in:
69
tests/unit/content-schemas.test.ts
Normal file
69
tests/unit/content-schemas.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
parsePortfolioContent,
|
||||
parsePublicAssetManifest,
|
||||
parseSiteConfig,
|
||||
validateProductionInputs,
|
||||
} from '@/content/schemas';
|
||||
|
||||
const fixture = (name: string): unknown =>
|
||||
JSON.parse(readFileSync(resolve(process.cwd(), 'tests/fixtures', name), 'utf8'));
|
||||
|
||||
const validContent = fixture('portfolio-content.valid.json');
|
||||
const validManifest = fixture('asset-manifest.valid.json');
|
||||
const validSite = fixture('site-config.preview.json');
|
||||
type MutableContentFixture = {
|
||||
navigation: Array<{ id: string }>;
|
||||
projects: {
|
||||
selected: Array<{ visibility: string; url?: string; assetIds: string[]; title: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
const cloneContent = (): MutableContentFixture => structuredClone(validContent) as MutableContentFixture;
|
||||
|
||||
describe('content schemas', () => {
|
||||
it('accepts the complete synthetic public input boundary', () => {
|
||||
expect(parsePortfolioContent(validContent).identity.name).toBe('Luca Pîslaru');
|
||||
expect(parsePublicAssetManifest(validManifest).assets).toHaveLength(5);
|
||||
expect(parseSiteConfig(validSite).environment).toBe('preview');
|
||||
});
|
||||
|
||||
it('rejects a public link on a private project', () => {
|
||||
const input = cloneContent();
|
||||
input.projects.selected[0]!.visibility = 'private';
|
||||
input.projects.selected[0]!.url = 'https://example.test/private';
|
||||
expect(() => parsePortfolioContent(input)).toThrow(/private project.*url/i);
|
||||
});
|
||||
|
||||
it('rejects unknown site configuration keys', () => {
|
||||
expect(() => parseSiteConfig({
|
||||
environment: 'preview',
|
||||
canonicalUrl: 'https://portfolio.example.test',
|
||||
buildId: 'preview-1',
|
||||
contactEmail: 'forbidden@example.test',
|
||||
})).toThrow(/unrecognized/i);
|
||||
});
|
||||
|
||||
it('rejects an asset reference absent from the manifest', () => {
|
||||
const content = cloneContent();
|
||||
content.projects.selected[0]!.assetIds = ['missing-asset'];
|
||||
expect(() => validateProductionInputs(content, validManifest, validSite, new URL('file:///prepared/')))
|
||||
.toThrow(/unmanifested asset/i);
|
||||
});
|
||||
|
||||
it('rejects duplicate manifest asset IDs and unsafe generated filenames', () => {
|
||||
const manifest = structuredClone(validManifest) as { assets: Array<{ id: string; fileName: string }> };
|
||||
manifest.assets[1]!.id = manifest.assets[0]!.id;
|
||||
manifest.assets[2]!.fileName = '../original.webp';
|
||||
expect(() => parsePublicAssetManifest(manifest)).toThrow(/duplicate asset id|generated safe filename/i);
|
||||
});
|
||||
|
||||
it('requires canonical navigation and generic private titles', () => {
|
||||
const content = cloneContent();
|
||||
content.navigation[7]!.id = 'profile';
|
||||
content.projects.selected[0]!.title = 'Private medical tracker';
|
||||
expect(() => parsePortfolioContent(content)).toThrow(/navigation|private project title/i);
|
||||
});
|
||||
});
|
||||
67
tests/unit/production-inputs.test.ts
Normal file
67
tests/unit/production-inputs.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { cp, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { assetUrl } from '@/content/asset-url';
|
||||
import { loadProductionInputs } from '@/content/load-production-inputs';
|
||||
import { parsePublicAssetManifest } from '@/content/schemas';
|
||||
|
||||
const fixtureUrl = (name: string) => pathToFileURL(resolve(process.cwd(), 'tests/fixtures', name));
|
||||
const fixtureUrls = {
|
||||
valid: {
|
||||
contentJson: fixtureUrl('portfolio-content.valid.json'),
|
||||
assetManifestJson: fixtureUrl('asset-manifest.valid.json'),
|
||||
preparedAssetRoot: fixtureUrl('prepared-assets.valid/'),
|
||||
siteConfigJson: fixtureUrl('site-config.preview.json'),
|
||||
},
|
||||
extraPreparedFile: {
|
||||
contentJson: fixtureUrl('portfolio-content.valid.json'),
|
||||
assetManifestJson: fixtureUrl('asset-manifest.valid.json'),
|
||||
preparedAssetRoot: fixtureUrl('prepared-assets.extra/'),
|
||||
siteConfigJson: fixtureUrl('site-config.preview.json'),
|
||||
},
|
||||
};
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('production inputs', () => {
|
||||
it('rejects an extra prepared file', async () => {
|
||||
await expect(loadProductionInputs(fixtureUrls.extraPreparedFile)).rejects.toThrow(/extra prepared asset/i);
|
||||
});
|
||||
|
||||
it('returns deeply frozen inputs', async () => {
|
||||
const inputs = await loadProductionInputs(fixtureUrls.valid);
|
||||
expect(Object.isFrozen(inputs.content.projects.selected)).toBe(true);
|
||||
expect(() => { (inputs.site as { buildId: string }).buildId = 'mutated'; }).toThrow();
|
||||
});
|
||||
|
||||
it('rejects a symlinked prepared member', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'portfolio-inputs-'));
|
||||
temporaryDirectories.push(directory);
|
||||
await cp(fixtureUrls.valid.preparedAssetRoot, directory, { recursive: true });
|
||||
await rm(join(directory, 'selected-build-one.webp'));
|
||||
await symlink(join(directory, 'selected-build-two.webp'), join(directory, 'selected-build-one.webp'));
|
||||
await expect(loadProductionInputs({ ...fixtureUrls.valid, preparedAssetRoot: new URL(`file://${directory}/`) }))
|
||||
.rejects.toThrow(/symlink|special file/i);
|
||||
});
|
||||
|
||||
it('rejects duplicate JSON keys before schema validation', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'portfolio-inputs-'));
|
||||
temporaryDirectories.push(directory);
|
||||
const contentPath = join(directory, 'content.json');
|
||||
await writeFile(contentPath, '{"identity":{},"identity":{}}');
|
||||
await expect(loadProductionInputs({ ...fixtureUrls.valid, contentJson: new URL(`file://${contentPath}`) }))
|
||||
.rejects.toThrow(/duplicate JSON key/i);
|
||||
});
|
||||
|
||||
it('resolves only an exact manifest asset ID to an encoded deterministic URL', () => {
|
||||
const manifest = parsePublicAssetManifest(JSON.parse(readFileSync(fixtureUrl('asset-manifest.valid.json'), 'utf8')));
|
||||
expect(assetUrl(manifest, 'selected-build-one')).toBe('/assets/selected-build-one.webp');
|
||||
expect(() => assetUrl(manifest, 'missing')).toThrow(/unknown asset/i);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user