Add initial project files

This commit is contained in:
Igor Martyniuk
2026-09-01 13:03:41 +03:00
parent 22e111bb44
commit 74cb5b6176
1841 changed files with 242819 additions and 30 deletions
@@ -0,0 +1,39 @@
import { createIntl } from 'react-intl';
import { describe, expect, it } from 'vitest';
import { WEB_STUDIO_BLOCK_CONFIG_BY_TYPE, WEB_STUDIO_BLOCK_CONFIGS } from '#core/WebStudio/config/blocks';
import en from '#core/WebStudio/config/translation/en.json';
import { BlockType } from '#core/WebStudio/entities/BlockType';
import { createBlockSchema } from '#core/WebStudio/view/forms/blocks/blockSchema';
const intl = createIntl({ locale: 'en', messages: en });
const blockSchema = createBlockSchema(intl);
describe('WEB_STUDIO_BLOCK_CONFIGS', () => {
it('covers every block type exactly once', () => {
const types = WEB_STUDIO_BLOCK_CONFIGS.map((config) => config.type);
expect([...types].sort()).toEqual(Object.values(BlockType).sort());
expect(new Set(types).size).toBe(types.length);
});
it.each(WEB_STUDIO_BLOCK_CONFIGS.map((config) => [config.type, config] as const))(
'createBlock for "%s" produces a block that passes the block schema',
(type, config) => {
const block = config.createBlock('some-id');
const result = blockSchema.safeParse(block);
expect(result.success).toBe(true);
expect(block.type).toBe(type);
expect(block.id).toBe('some-id');
if (result.success) {
expect(result.data).toEqual(block);
}
}
);
it('indexes every config by its type', () => {
for (const config of WEB_STUDIO_BLOCK_CONFIGS) {
expect(WEB_STUDIO_BLOCK_CONFIG_BY_TYPE[config.type]).toBe(config);
}
});
});
@@ -0,0 +1,69 @@
import type { IntlShape } from 'react-intl';
import { describe, expect, it } from 'vitest';
import { getPermittedMenuItems } from '#app/layouts/06__MultipleMenus/lib';
import { createWebStudioMenu } from '#core/WebStudio/config/menu';
const intl = { formatMessage: ({ id }: { id: string }) => id } as IntlShape;
/** Flattens the surviving tree to the set of reachable paths. */
function visiblePaths(permissions: string[]): string[] {
const [menu] = getPermittedMenuItems([createWebStudioMenu(intl)], permissions);
const paths: string[] = [];
const walk = (items: NonNullable<ReturnType<typeof createWebStudioMenu>['children']>) => {
for (const item of items) {
if (item.path) paths.push(item.path);
if (item.children) walk(item.children);
}
};
if (menu?.children) walk(menu.children);
return paths;
}
describe('Web Studio menu permissions', () => {
it('hides the whole module from an admin without web_manager permissions', () => {
expect(getPermittedMenuItems([createWebStudioMenu(intl)], [])).toEqual([]);
expect(getPermittedMenuItems([createWebStudioMenu(intl)], ['file_manager.view'])).toEqual([]);
});
it('shows only the listing to a view-only admin', () => {
const paths = visiblePaths(['web_manager.view']);
expect(paths).toContain('/web-studio/pages/listing');
expect(paths).not.toContain('/web-studio/pages/-/create');
expect(paths).not.toContain('/web-studio/settings/header');
});
it('reveals page creation only with view and create together', () => {
expect(visiblePaths(['web_manager.create'])).not.toContain('/web-studio/pages/-/create');
expect(visiblePaths(['web_manager.view', 'web_manager.create'])).toContain(
'/web-studio/pages/-/create'
);
});
it('reveals settings only with view and settings together', () => {
expect(visiblePaths(['web_manager.settings'])).not.toContain('/web-studio/settings/header');
const paths = visiblePaths(['web_manager.view', 'web_manager.settings']);
expect(paths).toContain('/web-studio/settings/header');
expect(paths).toContain('/web-studio/settings/footer');
expect(paths).toContain('/web-studio/settings/legal');
});
it('shows everything to an admin holding every web_manager permission', () => {
const paths = visiblePaths([
'web_manager.view',
'web_manager.create',
'web_manager.update',
'web_manager.publish',
'web_manager.delete',
'web_manager.settings'
]);
expect(paths).toContain('/web-studio/pages/listing');
expect(paths).toContain('/web-studio/pages/-/create');
expect(paths).toContain('/web-studio/settings/legal');
});
});
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import PagePayloadSerializer from '#core/WebStudio/data/api/PagePayloadSerializer';
import type { CreatePagePayloadDTO } from '#core/WebStudio/data/api/WebStudioDTO';
const createPayload = (): CreatePagePayloadDTO => ({
locale: 'en',
path: 'about-us',
groupKey: 'about-us',
pageType: 'landing',
title: 'About us',
hero: {
type: 'middle',
title: '',
ctaItems: [
{
linkType: 'external',
externalUrl: 'https://example.com',
openInNewTab: false,
label: 'Go',
variant: 'primary'
}
],
media: {
url: {
name: 'local-hero.png',
file: new File(['hero'], 'local-hero.png', { type: 'image/png' })
},
alt: ''
}
},
seoTitle: '',
seoDescription: '',
components: [
{
type: 'media',
data: { media: { url: { name: 'cloud.png', ref: '/cloud.png' }, alt: 'Cloud' } }
},
{
type: 'media',
data: {
media: {
url: { name: 'local-2.png', file: new File(['x'], 'local-2.png', { type: 'image/png' }) },
alt: ''
}
}
}
]
});
describe('PagePayloadSerializer', () => {
it('replaces local files with file keys and appends them as binary parts', () => {
const formData = PagePayloadSerializer.serialize(createPayload());
const json = JSON.parse(formData.get('payload') as string);
expect(json.hero.media.url).toEqual({ name: 'local-hero.png', fileKey: 'file-0' });
expect(json.components[1].data.media.url).toEqual({ name: 'local-2.png', fileKey: 'file-1' });
expect(formData.get('file-0')).toBeInstanceOf(File);
expect((formData.get('file-0') as File).name).toBe('local-hero.png');
expect((formData.get('file-1') as File).name).toBe('local-2.png');
});
it('keeps cloud references untouched', () => {
const formData = PagePayloadSerializer.serialize(createPayload());
const json = JSON.parse(formData.get('payload') as string);
expect(json.components[0].data.media.url).toEqual({ name: 'cloud.png', ref: '/cloud.png' });
});
it('preserves value types that bracket-notation multipart would lose', () => {
const formData = PagePayloadSerializer.serialize(createPayload());
const json = JSON.parse(formData.get('payload') as string);
expect(json.hero.ctaItems[0].openInNewTab).toBe(false);
expect(json.hero.title).toBe('');
});
it('sends no binary parts when the payload has no local files', () => {
const payload = createPayload();
payload.hero = { type: 'middle', ctaItems: [], media: { url: null, alt: '' } };
payload.components = [payload.components[0]];
const formData = PagePayloadSerializer.serialize(payload);
const json = JSON.parse(formData.get('payload') as string);
expect(formData.get('file-0')).toBeNull();
expect(json.hero.media.url).toBeNull();
});
});
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import type { PageListItemDTO } from '#core/WebStudio/data/api/WebStudioDTO';
import PageListMapper from '#core/WebStudio/data/mappers/PageListMapper';
import { PageStatus } from '#core/WebStudio/entities/PageStatus';
describe('PageListMapper', () => {
it('maps list fields and the draft clone identifier', () => {
const dto: PageListItemDTO = {
id: 12,
status: 'published',
draftOfId: null,
locale: 'en',
path: 'company/about-us',
groupKey: 'about',
pageType: 'landing',
title: 'About us',
seoTitle: 'About Crux',
updatedAt: '2026-07-23T10:20:30.000Z',
draft: { id: 18 }
};
expect(PageListMapper.toEntity(dto)).toEqual({
id: 12,
status: PageStatus.PUBLISHED,
draftOfId: null,
locale: 'en',
path: 'company/about-us',
groupKey: 'about',
pageType: 'landing',
title: 'About us',
seoTitle: 'About Crux',
updatedAt: '2026-07-23T10:20:30.000Z',
draftId: 18
});
});
it('uses null when a page has no draft clone', () => {
const dto = {
id: 13,
status: 'draft',
draftOfId: null,
locale: 'uk',
path: 'pro-nas',
groupKey: 'about',
pageType: 'landing',
title: 'Про нас',
seoTitle: 'Про нас',
updatedAt: '2026-07-23T10:20:30.000Z'
} satisfies PageListItemDTO;
expect(PageListMapper.toEntity(dto).draftId).toBeNull();
});
});
@@ -0,0 +1,93 @@
import { createIntl } from 'react-intl';
import { describe, expect, it } from 'vitest';
import en from '#core/WebStudio/config/translation/en.json';
import type { PageDetailsDTO } from '#core/WebStudio/data/api/WebStudioDTO';
import toEditorValues from '#core/WebStudio/pages/PageEditPage/helpers/toEditorValues';
import { createValidationSchema } from '#core/WebStudio/view/forms/PageEditorForm/validationSchema';
const intl = createIntl({ locale: 'en', messages: en });
const validationSchema = createValidationSchema(intl);
const createDetails = (): PageDetailsDTO => ({
page: {
id: 9,
status: 'draft',
draftOfId: null,
locale: 'en',
path: 'about',
groupKey: 'about',
pageType: 'landing',
title: 'About',
hero: {
type: 'middle',
media: { alt: null, url: null },
title: null,
content: { content: null },
ctaItems: [],
subtitle: null
},
seoTitle: '',
seoDescription: '',
publishedAt: null,
createdAt: '2026-07-24T07:26:32.285+00:00',
updatedAt: '2026-07-24T07:26:32.285+00:00'
},
components: [
{
id: 8,
pageId: 9,
type: 'content',
data: {
title: null,
subtitle: null,
columns: [{ content: { content: null }, ctaItems: null }]
}
}
]
});
describe('toEditorValues', () => {
it('replaces legacy null values with editor defaults', () => {
const values = toEditorValues(createDetails());
expect(values.hero.title).toBe('');
expect(values.hero.subtitle).toBe('');
expect(values.hero.content).toEqual({ content: '' });
expect(values.hero.media).toEqual({ url: null, alt: '' });
const contentBlock = values.blocks[0] as {
title: string;
subtitle: string;
columns: Array<{ content: { content: string }; ctaItems: unknown[] }>;
};
expect(contentBlock.title).toBe('');
expect(contentBlock.subtitle).toBe('');
expect(contentBlock.columns[0].content).toEqual({ content: '' });
expect(contentBlock.columns[0].ctaItems).toEqual([]);
});
it('produces values that pass the editor validation schema', () => {
const values = toEditorValues(createDetails());
const result = validationSchema.safeParse(values);
expect(result.success).toBe(true);
});
it('keeps stored values when they are present', () => {
const details = createDetails();
details.page.hero = {
type: 'high',
media: { alt: 'Alt', url: { name: 'a.png', ref: '/a.png' } },
title: 'Hero title',
content: { content: '<p>Body</p>' },
ctaItems: [],
subtitle: 'Sub'
};
const values = toEditorValues(details);
expect(values.hero.type).toBe('high');
expect(values.hero.title).toBe('Hero title');
expect(values.hero.media).toEqual({ alt: 'Alt', url: { name: 'a.png', ref: '/a.png' } });
});
});
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { toApiPath, toFormPath } from '#core/WebStudio/services/pagePath';
describe('pagePath', () => {
describe('toFormPath', () => {
it('converts the home API path to "/"', () => {
expect(toFormPath('')).toBe('/');
});
it('prefixes an API path with a slash', () => {
expect(toFormPath('blog/post')).toBe('/blog/post');
});
it('keeps an already-prefixed path unchanged', () => {
expect(toFormPath('/about')).toBe('/about');
});
it('collapses repeated leading slashes to one', () => {
expect(toFormPath('///about')).toBe('/about');
});
});
describe('toApiPath', () => {
it('converts the home form path to ""', () => {
expect(toApiPath('/')).toBe('');
});
it('strips the leading slash', () => {
expect(toApiPath('/blog/post')).toBe('blog/post');
});
it('strips repeated leading slashes', () => {
expect(toApiPath('///about')).toBe('about');
});
it('leaves a path without a leading slash unchanged', () => {
expect(toApiPath('about')).toBe('about');
});
});
describe('round-trip', () => {
it.each(['', 'about', 'blog/post'])('toApiPath(toFormPath("%s")) returns the input', (path) => {
expect(toApiPath(toFormPath(path))).toBe(path);
});
it.each(['/', '/about', '/blog/post'])(
'toFormPath(toApiPath("%s")) returns the input',
(path) => {
expect(toFormPath(toApiPath(path))).toBe(path);
}
);
it('does not strip inner or trailing slashes', () => {
expect(toApiPath('/a//b/')).toBe('a//b/');
expect(toFormPath('a//b/')).toBe('/a//b/');
});
});
});
@@ -0,0 +1,72 @@
import { createIntl } from 'react-intl';
import { describe, expect, it } from 'vitest';
import en from '#core/WebStudio/config/translation/en.json';
import { getSeoDescriptionQuality, getSeoTextQuality, getSeoTitleQuality } from '#core/WebStudio/services/seo';
import { createPageEditorInitialValues } from '#core/WebStudio/view/forms/PageEditorForm/form';
import { createValidationSchema } from '#core/WebStudio/view/forms/PageEditorForm/validationSchema';
const intl = createIntl({ locale: 'en', messages: en });
const validationSchema = createValidationSchema(intl);
describe('SEO text quality', () => {
it('marks empty text as bad', () => {
expect(getSeoTextQuality(' ', 30, 60)).toMatchObject({
status: 'bad',
reason: 'empty',
length: 0
});
});
it('marks text below the recommended minimum as a warning', () => {
expect(getSeoTitleQuality('Short title')).toMatchObject({
status: 'warning',
reason: 'short'
});
});
it('marks text inside the recommended range as good', () => {
expect(getSeoTitleQuality('A clear and descriptive page title')).toMatchObject({
status: 'good',
reason: 'good'
});
});
it('marks text over the maximum as bad', () => {
expect(getSeoDescriptionQuality('x'.repeat(161))).toMatchObject({
status: 'bad',
reason: 'long',
length: 161
});
});
it('accepts SEO text over the recommended length; the quality indicator flags it instead', () => {
const values = createPageEditorInitialValues({
title: 'Page title',
groupKey: 'page-title'
});
values.seo.title = 'x'.repeat(61);
values.seo.description = 'x'.repeat(161);
expect(validationSchema.safeParse(values).success).toBe(true);
expect(getSeoTitleQuality(values.seo.title)).toMatchObject({ status: 'bad', reason: 'long' });
expect(getSeoDescriptionQuality(values.seo.description)).toMatchObject({
status: 'bad',
reason: 'long'
});
});
it('rejects SEO text over the backend storage bounds', () => {
const values = createPageEditorInitialValues({
title: 'Page title',
groupKey: 'page-title'
});
values.seo.title = 'x'.repeat(256);
expect(validationSchema.safeParse(values).success).toBe(false);
values.seo.title = '';
values.seo.description = 'x'.repeat(2001);
expect(validationSchema.safeParse(values).success).toBe(false);
});
});
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import { WEB_STUDIO_BLOCK_CONFIG_BY_TYPE } from '#core/WebStudio/config/blocks';
import { BlockType } from '#core/WebStudio/entities/BlockType';
import { HeroType } from '#core/WebStudio/entities/HeroType';
import { PageType } from '#core/WebStudio/entities/PageType';
import { createPageEditorInitialValues } from '#core/WebStudio/view/forms/PageEditorForm/form';
import { toCreatePayload } from '#core/WebStudio/view/forms/PageEditorForm/helpers/toCreatePayload';
const createBlock = (type: BlockType, id: string) =>
WEB_STUDIO_BLOCK_CONFIG_BY_TYPE[type].createBlock(id);
describe('toCreatePayload', () => {
it('filters out blocks that are not allowed for the page type', () => {
const values = createPageEditorInitialValues({
title: 'Post',
groupKey: 'post',
pageType: PageType.BLOG_ITEM,
path: '/blog/post',
blocks: [createBlock(BlockType.CONTENT, 'b1'), createBlock(BlockType.ARTICLES, 'b2')]
});
const payload = toCreatePayload(values, 'en');
expect(payload.components).toHaveLength(1);
expect(payload.components[0].type).toBe(BlockType.CONTENT);
});
it('forces the fixed path for the blog page type regardless of the form path', () => {
const values = createPageEditorInitialValues({
title: 'Blog',
groupKey: 'blog',
pageType: PageType.BLOG,
path: '/whatever'
});
const payload = toCreatePayload(values, 'en');
expect(payload.path).toBe('blog');
});
it('strips block ids and nests remaining block fields under data', () => {
const values = createPageEditorInitialValues({
title: 'Landing',
groupKey: 'landing',
pageType: PageType.LANDING,
blocks: [createBlock(BlockType.FAQ, 'faq-id')]
});
const payload = toCreatePayload(values, 'en');
expect(payload.components).toEqual([
{
type: BlockType.FAQ,
data: {
title: '',
subtitle: '',
items: [{ question: '', answer: '' }]
}
}
]);
expect(payload.components[0]).not.toHaveProperty('id');
expect(payload.components[0].data).not.toHaveProperty('id');
});
it('converts the form path to an API path (leading slash removed)', () => {
const values = createPageEditorInitialValues({
title: 'About',
groupKey: 'about',
pageType: PageType.LANDING,
path: '/about/team'
});
expect(toCreatePayload(values, 'en').path).toBe('about/team');
});
it('converts the home form path "/" to the empty API path', () => {
const values = createPageEditorInitialValues({
title: 'Home',
groupKey: 'home',
pageType: PageType.LANDING,
path: '/'
});
expect(toCreatePayload(values, 'en').path).toBe('');
});
it('passes through locale, hero and SEO fields', () => {
const values = createPageEditorInitialValues({
title: 'About',
groupKey: 'about',
pageType: PageType.LANDING,
path: '/about'
});
values.hero = {
type: HeroType.HIGH,
title: 'Hero title',
subtitle: 'Hero subtitle',
content: { content: '<p>Body</p>' },
ctaItems: [],
media: { url: null, alt: '' }
};
values.seo.title = 'SEO title';
values.seo.description = 'SEO description';
const payload = toCreatePayload(values, 'uk');
expect(payload.locale).toBe('uk');
expect(payload.groupKey).toBe('about');
expect(payload.pageType).toBe(PageType.LANDING);
expect(payload.title).toBe('About');
expect(payload.hero).toEqual(values.hero);
expect(payload.hero).not.toBe(values.hero);
expect(payload.seoTitle).toBe('SEO title');
expect(payload.seoDescription).toBe('SEO description');
});
});
@@ -0,0 +1,113 @@
import { createIntl } from 'react-intl';
import { describe, expect, it } from 'vitest';
import { WEB_STUDIO_BLOCK_CONFIG_BY_TYPE } from '#core/WebStudio/config/blocks';
import en from '#core/WebStudio/config/translation/en.json';
import { BlockType } from '#core/WebStudio/entities/BlockType';
import { HeroType } from '#core/WebStudio/entities/HeroType';
import { createPageEditorInitialValues } from '#core/WebStudio/view/forms/PageEditorForm/form';
import { createValidationSchema } from '#core/WebStudio/view/forms/PageEditorForm/validationSchema';
const intl = createIntl({ locale: 'en', messages: en });
const validationSchema = createValidationSchema(intl);
const createValues = () =>
createPageEditorInitialValues({ title: 'Page title', groupKey: 'page-title' });
const parseWith = (patch: Partial<ReturnType<typeof createValues>>) =>
validationSchema.safeParse({ ...createValues(), ...patch });
describe('page editor validation schema', () => {
describe('path', () => {
it.each(['/', '/about', '/a-b/c'])('accepts "%s"', (path) => {
expect(parseWith({ path }).success).toBe(true);
});
it.each(['/about/', '/About', '//a', 'about', '/a--b', '/-a'])('rejects "%s"', (path) => {
expect(parseWith({ path }).success).toBe(false);
});
it('trims surrounding whitespace before validating', () => {
const result = parseWith({ path: ' /about ' });
expect(result.success).toBe(true);
if (result.success) expect(result.data.path).toBe('/about');
});
});
describe('groupKey', () => {
it.each(['about', 'a-b', 'a1-b2'])('accepts "%s"', (groupKey) => {
expect(parseWith({ groupKey }).success).toBe(true);
});
it.each(['', 'About', 'a_b', '-a', 'a-', 'a b', 'a--b'])('rejects "%s"', (groupKey) => {
expect(parseWith({ groupKey }).success).toBe(false);
});
it('rejects a group key over 255 characters', () => {
expect(parseWith({ groupKey: 'a'.repeat(256) }).success).toBe(false);
});
});
describe('title', () => {
it('rejects an empty title', () => {
expect(parseWith({ title: '' }).success).toBe(false);
});
it('rejects a whitespace-only title (trimmed before the min check)', () => {
expect(parseWith({ title: ' ' }).success).toBe(false);
});
it('trims the title', () => {
const result = parseWith({ title: ' Hello ' });
expect(result.success).toBe(true);
if (result.success) expect(result.data.title).toBe('Hello');
});
it('rejects a title over 255 characters and accepts one at the limit', () => {
expect(parseWith({ title: 'x'.repeat(255) }).success).toBe(true);
expect(parseWith({ title: 'x'.repeat(256) }).success).toBe(false);
});
});
describe('blocks', () => {
const buildBlocks = (count: number) =>
Array.from({ length: count }, (_, index) =>
WEB_STUDIO_BLOCK_CONFIG_BY_TYPE[BlockType.CONTENT].createBlock(`block-${index}`)
);
it('accepts 200 blocks', () => {
expect(parseWith({ blocks: buildBlocks(200) }).success).toBe(true);
});
it('rejects 201 blocks', () => {
expect(parseWith({ blocks: buildBlocks(201) }).success).toBe(false);
});
});
describe('hero', () => {
const heroBase = {
title: 'Hero',
subtitle: 'Sub',
content: { content: '' },
ctaItems: [],
media: { url: null, alt: '' }
};
it.each([HeroType.NONE, HeroType.LOW, HeroType.MIDDLE, HeroType.HIGH])(
'accepts hero type "%s"',
(type) => {
expect(parseWith({ hero: { ...heroBase, type } }).success).toBe(true);
}
);
it('rejects an unknown hero type', () => {
const result = validationSchema.safeParse({
...createValues(),
hero: { ...heroBase, type: 'huge' }
});
expect(result.success).toBe(false);
});
});
});
@@ -0,0 +1,104 @@
import { createIntl } from 'react-intl';
import { describe, expect, it } from 'vitest';
import en from '#core/WebStudio/config/translation/en.json';
import { createMediaFieldSchema } from '#core/WebStudio/view/forms/fields/MediaField/schema';
import { createNavigationLinkSchema } from '#core/WebStudio/view/forms/fields/NavigationLinkField/schema';
const intl = createIntl({ locale: 'en', messages: en });
describe('navigation link schema', () => {
const schema = createNavigationLinkSchema(intl);
const base = {
openInNewTab: false,
label: 'Read more',
variant: 'primary' as const
};
it('accepts an internal link with a pageId', () => {
const result = schema.safeParse({ ...base, linkType: 'internal', pageId: '42' });
expect(result.success).toBe(true);
});
it('rejects an internal link with an empty pageId', () => {
expect(schema.safeParse({ ...base, linkType: 'internal', pageId: '' }).success).toBe(false);
expect(schema.safeParse({ ...base, linkType: 'internal', pageId: ' ' }).success).toBe(false);
});
it('rejects an internal link without a pageId', () => {
expect(schema.safeParse({ ...base, linkType: 'internal' }).success).toBe(false);
});
it('accepts an external link with a valid URL', () => {
const result = schema.safeParse({
...base,
linkType: 'external',
externalUrl: 'https://example.com/page'
});
expect(result.success).toBe(true);
});
it('rejects an external link with an invalid URL', () => {
expect(
schema.safeParse({ ...base, linkType: 'external', externalUrl: 'not-a-url' }).success
).toBe(false);
});
it('rejects an unknown link type', () => {
expect(schema.safeParse({ ...base, linkType: 'anchor', pageId: '42' }).success).toBe(false);
});
it('rejects an unknown variant', () => {
expect(
schema.safeParse({ ...base, variant: 'danger', linkType: 'internal', pageId: '42' }).success
).toBe(false);
});
});
describe('media field schema', () => {
const schema = createMediaFieldSchema(intl);
it('accepts a cloud file value ({name, ref})', () => {
const result = schema.safeParse({
url: { name: 'photo.png', ref: '/media/photo.png' },
alt: 'Photo'
});
expect(result.success).toBe(true);
});
it('accepts a local file value ({name, file: File})', () => {
const result = schema.safeParse({
url: { name: 'x.png', file: new File([], 'x.png', { type: 'image/png' }) },
alt: ''
});
expect(result.success).toBe(true);
});
it('accepts a null url', () => {
expect(schema.safeParse({ url: null, alt: '' }).success).toBe(true);
});
it('rejects a file value with neither ref nor file', () => {
expect(schema.safeParse({ url: { name: 'x.png' }, alt: '' }).success).toBe(false);
});
it('accepts optional dimensions and mime type', () => {
const result = schema.safeParse({
url: { name: 'photo.png', ref: '/media/photo.png' },
alt: 'Photo',
width: 100,
height: 50,
mimeType: 'image/png'
});
expect(result.success).toBe(true);
});
it('rejects a missing alt', () => {
expect(schema.safeParse({ url: null }).success).toBe(false);
});
});