Files
immich-folio/lib/__tests__/content-version.test.ts

195 lines
7.7 KiB
TypeScript
Raw Permalink Normal View History

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'fs/promises';
import {
ABSENT_VERSION,
VersionConflictError,
assertVersion,
baseVersionFrom,
fileVersion,
serializeContentWrite,
versionOf,
} from '../admin/contentVersion';
/**
* Concurrent-edit detection (#601): a save carrying the version its editor
* loaded is refused when the file changed since; a save without one is
* written as before.
*
* The services resolve content/ from process.cwd() at import time, so each
* test points cwd at a fresh temp directory and re-imports them.
*/
let root: string;
let contentDir: string;
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'folio-version-'));
contentDir = path.join(root, 'content');
await mkdir(path.join(contentDir, 'journal'), { recursive: true });
vi.spyOn(process, 'cwd').mockReturnValue(root);
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.resetModules();
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(root, { recursive: true, force: true });
});
/** By name: the services are re-imported per test, so their error class is a different copy. */
const conflict = expect.objectContaining({ name: 'VersionConflictError' });
const put = (ifMatch?: string) =>
new Request('http://localhost/x', {
method: 'PUT',
headers: ifMatch === undefined ? {} : { 'If-Match': ifMatch },
});
describe('contentVersion helpers', () => {
it('versions content by its bytes, and a missing file as absent', async () => {
expect(versionOf('a')).toBe(versionOf(Buffer.from('a')));
expect(versionOf('a')).not.toBe(versionOf('b'));
expect(versionOf(null)).toBe(ABSENT_VERSION);
await expect(fileVersion(path.join(root, 'nope'))).resolves.toBe(ABSENT_VERSION);
await writeFile(path.join(root, 'f'), 'a');
await expect(fileVersion(path.join(root, 'f'))).resolves.toBe(versionOf('a'));
});
it('reads If-Match in bare, quoted and weak form; * and absent mean no check', () => {
expect(baseVersionFrom(put('abc'))).toBe('abc');
expect(baseVersionFrom(put('"abc"'))).toBe('abc');
expect(baseVersionFrom(put('W/"abc"'))).toBe('abc');
expect(baseVersionFrom(put('*'))).toBeUndefined();
expect(baseVersionFrom(put())).toBeUndefined();
});
it('assertVersion passes on a match or no base, and throws with the current version', async () => {
const file = path.join(root, 'f');
await writeFile(file, 'now');
await expect(assertVersion(file, versionOf('now'))).resolves.toBeUndefined();
await expect(assertVersion(file, undefined)).resolves.toBeUndefined();
const err = await assertVersion(file, versionOf('before')).catch((e) => e);
expect(err).toBeInstanceOf(VersionConflictError);
expect(err.currentVersion).toBe(versionOf('now'));
});
it('runs queued writes one at a time, and a failure does not stall the queue', async () => {
const order: string[] = [];
const slow = serializeContentWrite(async () => {
order.push('a:start');
await new Promise((r) => setTimeout(r, 20));
order.push('a:end');
});
const failing = serializeContentWrite(async () => {
order.push('b');
throw new Error('boom');
});
const after = serializeContentWrite(async () => order.push('c'));
await Promise.allSettled([slow, failing, after]);
await expect(failing).rejects.toThrow('boom');
expect(order).toEqual(['a:start', 'a:end', 'b', 'c']);
});
});
describe('yaml-service', () => {
it('writes settings.yaml when the base version matches, and refuses when it does not', async () => {
const service = await import('../admin/yaml-service');
const settingsPath = path.join(contentDir, 'settings.yaml');
await writeFile(settingsPath, 'title: One\n');
const loaded = await service.readSettingsYamlVersioned();
expect(loaded.data).toEqual({ title: 'One' });
// Another tab saves first.
const second = await service.writeSettingsYaml({ title: 'Two' }, loaded.version);
expect(second).toBe(versionOf(await readFile(settingsPath)));
// This tab still holds the first version.
await expect(service.writeSettingsYaml({ title: 'Three' }, loaded.version)).rejects.toEqual(
expect.objectContaining({ name: 'VersionConflictError', currentVersion: second }),
);
expect(await service.readSettingsYaml()).toEqual({ title: 'Two' });
});
it('still writes without a base version (older clients)', async () => {
const service = await import('../admin/yaml-service');
await writeFile(path.join(contentDir, 'gallery.yaml'), 'hero: []\n');
await service.writeGalleryYaml({ hero: ['x'] } as never);
expect(await service.readGalleryYaml()).toEqual({ hero: ['x'] });
});
it('treats a file created since an absent load as a conflict', async () => {
const service = await import('../admin/yaml-service');
const loaded = await service.readGalleryYamlVersioned();
expect(loaded.version).toBe(ABSENT_VERSION);
await writeFile(path.join(contentDir, 'gallery.yaml'), 'hero: []\n');
await expect(service.writeGalleryYaml({ hero: [] } as never, loaded.version)).rejects.toEqual(
conflict,
);
});
});
const md = (title: string) => `---\ntitle: "${title}"\n---\n\nBody of ${title}\n`;
describe('journal-service saveJournalEntry', () => {
it('saves on a matching version and refuses a stale one', async () => {
const service = await import('../admin/journal-service');
const file = path.join(contentDir, 'journal', 'trip.md');
await writeFile(file, md('One'));
const base = versionOf(md('One'));
await expect(service.saveJournalEntry('trip', md('Two'), { baseVersion: base })).resolves.toBe(
versionOf(md('Two')),
);
await expect(
service.saveJournalEntry('trip', md('Three'), { baseVersion: base }),
).rejects.toEqual(conflict);
await expect(readFile(file, 'utf8')).resolves.toBe(md('Two'));
// No base: written as before.
await service.saveJournalEntry('trip', md('Four'));
await expect(readFile(file, 'utf8')).resolves.toBe(md('Four'));
});
it('checks a rename against the source entry and leaves both files alone on conflict', async () => {
const service = await import('../admin/journal-service');
const dir = path.join(contentDir, 'journal');
await writeFile(path.join(dir, 'old.md'), md('Changed elsewhere'));
const err = await service
.saveJournalEntry('new', md('Mine'), {
baseVersion: versionOf(md('Loaded')),
fromSlug: 'old',
})
.catch((e) => e);
expect(err).toEqual(conflict);
expect((await readdir(dir)).filter((f) => f.endsWith('.md'))).toEqual(['old.md']);
await service.saveJournalEntry('new', md('Mine'), {
baseVersion: versionOf(md('Changed elsewhere')),
fromSlug: 'old',
});
expect((await readdir(dir)).filter((f) => f.endsWith('.md'))).toEqual(['new.md']);
});
});
describe('pages-service savePage', () => {
it('saves on a matching version, refuses a stale one, and renames', async () => {
const service = await import('../admin/pages-service');
const dir = path.join(contentDir, 'pages');
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, 'faq.md'), md('FAQ'));
await expect(
service.savePage('faq', md('Stale'), { baseVersion: versionOf(md('Other')) }),
).rejects.toEqual(conflict);
await expect(readFile(path.join(dir, 'faq.md'), 'utf8')).resolves.toBe(md('FAQ'));
await service.savePage('help', md('Help'), {
baseVersion: versionOf(md('FAQ')),
fromSlug: 'faq',
});
expect((await readdir(dir)).filter((f) => f.endsWith('.md'))).toEqual(['help.md']);
});
});