release: v0.16.0

See CHANGELOG.md for the full v0.16.0 entry.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
This commit is contained in:
Ralksta
2026-09-22 14:12:13 +02:00
committed by GitHub
parent c3f92ca4ec
commit a3a6319c29
120 changed files with 6818 additions and 975 deletions

View File

@@ -0,0 +1,71 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor, act } from '@testing-library/react';
import AdminShell from '../AdminShell';
import { SESSION_EXPIRED_EVENT } from '../components/sessionExpiry';
vi.mock('next/navigation', () => ({
usePathname: () => '/admin',
}));
afterEach(cleanup);
/**
* Sessions last 24 hours, but nothing re-checked authentication after the
* mount-time check below — leave a tab open overnight and every admin
* request answered 401 while the UI still looked signed in (#596).
* SESSION_EXPIRED_EVENT, raised by any admin fetch call site that notices a
* 401, is what this pins: it drops the whole panel back to the login screen
* with an explanation, instead of each call site failing quietly.
*/
describe('AdminShell session expiry', () => {
it('returns to the login screen with a notice when the session expires', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => {
if (url === '/api/admin/auth') {
return new Response(JSON.stringify({ authenticated: true, enabled: true }), {
status: 200,
});
}
// AdminDashboard's own status/doctor fetches — a failure there is
// already handled as "unknown", not the thing under test here.
return new Response(null, { status: 500 });
}),
);
render(
<AdminShell>
<div>Panel content</div>
</AdminShell>,
);
await waitFor(() => expect(screen.getByText('Panel content')).toBeTruthy());
expect(screen.queryByLabelText('Password')).toBeNull();
act(() => {
window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT));
});
await waitFor(() =>
expect(screen.getByText('Your session expired. Sign in again.')).toBeTruthy(),
);
expect(screen.queryByText('Panel content')).toBeNull();
});
it('shows no notice on an ordinary first visit', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify({ authenticated: false, enabled: true }))),
);
render(
<AdminShell>
<div>Panel content</div>
</AdminShell>,
);
await waitFor(() => expect(screen.getByLabelText('Password')).toBeTruthy());
expect(screen.queryByText('Your session expired. Sign in again.')).toBeNull();
});
});

View File

@@ -0,0 +1,88 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import SaveBar from '../components/SaveBar';
/**
* The first component test in the project, and deliberately a small one: it is
* here to show the shape (`// @vitest-environment jsdom` on the first line,
* render, assert, cleanup) as much as to cover SaveBar.
*
* SaveBar earns it though. It decides whether an outcome reads as success or
* failure by testing whether the message happens to start with "Error" — #600
* replaces that with a typed state, and this pins the behaviour so the swap can
* be verified rather than eyeballed.
*/
afterEach(cleanup);
const props = {
dirty: false,
saving: false,
saveMessage: '',
onSave: () => {},
label: 'Save',
};
describe('SaveBar', () => {
it('stays out of the way when there is nothing to act on', () => {
const { container } = render(<SaveBar {...props} />);
// It is pinned over the page, so rendering it with nothing to say would
// cover content for no reason.
expect(container.firstChild).toBeNull();
});
it('announces unsaved changes', () => {
render(<SaveBar {...props} dirty />);
expect(screen.getByText('Unsaved changes')).toBeTruthy();
});
it('only offers saving when there is something to save', () => {
const { rerender } = render(<SaveBar {...props} saveMessage="Saved!" />);
expect(screen.getByRole('button', { name: 'Save' })).toHaveProperty('disabled', true);
rerender(<SaveBar {...props} dirty />);
expect(screen.getByRole('button', { name: 'Save' })).toHaveProperty('disabled', false);
});
it('blocks a second save while one is in flight', () => {
render(<SaveBar {...props} dirty saving />);
const button = screen.getByRole('button', { name: 'Saving...' });
expect(button).toHaveProperty('disabled', true);
});
it('calls onSave when clicked', () => {
const onSave = vi.fn();
render(<SaveBar {...props} dirty onSave={onSave} />);
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSave).toHaveBeenCalledOnce();
});
it('tells a failure from a success by the message prefix', () => {
// Pinned as it is, not as it should be: the prefix check is the thing #600
// replaces. A message that fails without starting with "Error" is styled as
// a success today, which is precisely the argument for a typed state.
const { rerender } = render(<SaveBar {...props} saveMessage="Error: Failed to save" />);
expect(screen.getByText('Error: Failed to save').className).toContain('error');
rerender(<SaveBar {...props} saveMessage="Saved!" />);
expect(screen.getByText('Saved!').className).toContain('success');
rerender(<SaveBar {...props} saveMessage="Could not reach the server" />);
expect(screen.getByText('Could not reach the server').className).toContain('success');
});
it('offers the preview link only where it belongs', () => {
const { rerender } = render(<SaveBar {...props} dirty />);
expect(screen.queryByRole('link')).toBeNull();
rerender(<SaveBar {...props} dirty showPreview />);
// `?fresh=1` is what bypasses the cache, so the preview shows the save.
expect(screen.getByRole('link')).toHaveProperty('href', expect.stringContaining('/?fresh=1'));
});
});

View File

@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import {
parseAlbumEntries,
serializeAlbumEntries,
hasAlbumOptions,
} from '../components/page-builder/albumEntries';
import type { AlbumEntryObject } from '@/lib/config/schema';
/**
* The page builder rewrites gallery.yaml wholesale on every save, so a field it
* does not know about is not merely ignored — it is deleted. `location` was
* lost this way (#614): an album deliberately placed at city level, or hidden
* from the map, silently went back to exact coordinates the next time anything
* unrelated was saved.
*
* The round-trip test below is the part that matters. It is driven by a sample
* entry carrying every key of AlbumEntryObject, so the next field added to the
* schema and forgotten here fails this suite instead of quietly disappearing
* from someone's configuration.
*/
const uuid = '11111111-1111-1111-1111-111111111111';
/**
* One value per key of AlbumEntryObject. The type annotation is the enforcement:
* adding a key to the schema without adding it here is a type error, and the
* round-trip test then proves the builder preserves it.
*/
const everyOption: Required<Omit<AlbumEntryObject, 'grid'>> & Pick<AlbumEntryObject, 'grid'> = {
title: 'Kloster Chorin',
description: 'Brick gothic in the Uckermark',
password: 'hunter2',
heroImage: '22222222-2222-2222-2222-222222222222',
sort: 'oldest',
assetOrder: ['33333333-3333-3333-3333-333333333333'],
coverPosition: '50% 25%',
download: true,
location: 'hidden',
grid: { columns: 4, gap: 8, aspectRatio: '3/2', layout: 'masonry' },
};
describe('album entry round trip', () => {
it('preserves every option a gallery.yaml entry can carry', () => {
const parsed = parseAlbumEntries([{ [uuid]: everyOption }]);
const [serialized] = serializeAlbumEntries(parsed);
expect(serialized).toEqual({ [uuid]: everyOption });
});
it('keeps map precision across a save', () => {
const parsed = parseAlbumEntries([{ [uuid]: { location: 'city' } }]);
expect(parsed[0].location).toBe('city');
expect(serializeAlbumEntries(parsed)).toEqual([{ [uuid]: { location: 'city' } }]);
});
it('does not collapse a location-only entry back to a bare id', () => {
// This is the actual mechanism of the bug: an entry that looks "empty" is
// written back as a plain UUID string, taking its options with it.
const entry = { id: uuid, location: 'hidden' };
expect(hasAlbumOptions(entry)).toBe(true);
expect(serializeAlbumEntries([entry])).toEqual([{ [uuid]: { location: 'hidden' } }]);
});
it('carries a value this version does not recognise', () => {
// Narrowing belongs to deriveGallery(), which rejects a typo loudly. The
// builder's job is to hand back what it was given rather than drop it.
const parsed = parseAlbumEntries([{ [uuid]: { location: 'district' } }]);
expect(serializeAlbumEntries(parsed)).toEqual([{ [uuid]: { location: 'district' } }]);
});
it('still collapses an entry that really is bare', () => {
expect(serializeAlbumEntries([{ id: uuid }])).toEqual([uuid]);
expect(serializeAlbumEntries([{ id: uuid, title: 'Just a title' }])).toEqual([
{ [uuid]: 'Just a title' },
]);
});
});