In reading mode a plain "e" (guarded against typing targets) switches to edit mode. In edit mode the platform's native chord — Cmd on macOS, Ctrl elsewhere — +S saves an unnamed manual snapshot in place, and +Shift+S asks for a name and returns to reading mode; both always swallow the browser's save dialog. The shared isTypingTarget guard moves from TopBar into lib/keyboard.ts next to the new modifier helper. Unnamed snapshots needed the API to accept them: the version label is optional now (trigger stays MANUAL, label null), and the history list's existing null-label fallback text becomes "Manueller Schnappschuss" / "Manual snapshot" — it only ever shows for exactly those. DB test for the label-less path, e2e coverage in the CI content pack. Fixes #125 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn
197 lines
7.1 KiB
TypeScript
197 lines
7.1 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
|
|
import { INestApplication, NotFoundException } from '@nestjs/common';
|
|
import { PrismaClient, User } from '@prisma/client';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import * as Y from 'yjs';
|
|
|
|
import { createTestApp } from '../testing/test-app';
|
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
import { VERSION_RETENTION_DAYS, VersionsService } from './versions.service';
|
|
|
|
describe.skipIf(!hasTestDb)('VersionsService (db, issue #41)', () => {
|
|
let app: INestApplication;
|
|
let prisma: PrismaClient;
|
|
let versions: VersionsService;
|
|
const suffix = uniqueSuffix();
|
|
let owner: User;
|
|
let pondId: string;
|
|
const pageIds: string[] = [];
|
|
|
|
async function createPage(): Promise<string> {
|
|
const id = randomUUID();
|
|
await prisma.page.create({
|
|
data: {
|
|
id,
|
|
pondId,
|
|
title: 'Versioned',
|
|
slug: `p-${id.slice(0, 8)}`,
|
|
ydocState: new Uint8Array(Y.encodeStateAsUpdate(new Y.Doc())),
|
|
sortKey: 'a0',
|
|
createdBy: owner.id,
|
|
},
|
|
});
|
|
pageIds.push(id);
|
|
return id;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
prisma = createTestPrisma();
|
|
app = await createTestApp();
|
|
versions = app.get(VersionsService);
|
|
|
|
owner = await prisma.user.create({
|
|
data: {
|
|
username: `ver-owner-${suffix}`,
|
|
email: `ver-owner-${suffix}@example.test`,
|
|
displayName: 'Version Owner',
|
|
},
|
|
});
|
|
const pond = await prisma.pond.create({
|
|
data: {
|
|
slug: `ver-pond-${suffix}`,
|
|
name: 'Version Pond',
|
|
type: 'PERSONAL',
|
|
ownerId: owner.id,
|
|
},
|
|
});
|
|
pondId = pond.id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (pageIds.length > 0) await prisma.page.deleteMany({ where: { id: { in: pageIds } } });
|
|
await prisma.pond.deleteMany({ where: { id: pondId } });
|
|
await prisma.user.deleteMany({ where: { id: owner.id } });
|
|
await prisma.$disconnect();
|
|
await app.close();
|
|
});
|
|
|
|
it('creates a named version storing label and creator', async () => {
|
|
const pageId = await createPage();
|
|
const view = await versions.createNamed(owner, pageId, { label: 'before restructuring' });
|
|
|
|
expect(view).toMatchObject({
|
|
trigger: 'manual',
|
|
label: 'before restructuring',
|
|
createdBy: owner.id,
|
|
});
|
|
const row = await prisma.pageVersion.findUniqueOrThrow({ where: { id: view.id } });
|
|
expect(row.label).toBe('before restructuring');
|
|
expect(row.createdBy).toBe(owner.id);
|
|
expect(row.trigger).toBe('MANUAL');
|
|
expect(row.ydocSnapshot.byteLength).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('creates an unnamed manual snapshot when no label is given (#125)', async () => {
|
|
const pageId = await createPage();
|
|
const view = await versions.createNamed(owner, pageId, {});
|
|
|
|
expect(view).toMatchObject({ trigger: 'manual', label: null, createdBy: owner.id });
|
|
const row = await prisma.pageVersion.findUniqueOrThrow({ where: { id: view.id } });
|
|
expect(row.label).toBeNull();
|
|
});
|
|
|
|
it('captures and clears the pending contributor set', async () => {
|
|
const pageId = await createPage();
|
|
const authorA = randomUUID();
|
|
const authorB = randomUUID();
|
|
await prisma.pagePendingContributor.createMany({
|
|
data: [
|
|
{ pageId, userId: authorA },
|
|
{ pageId, userId: authorB },
|
|
],
|
|
});
|
|
|
|
const view = await versions.createNamed(owner, pageId, { label: 'snapshot' });
|
|
expect([...view.contributorIds].sort()).toEqual([authorA, authorB].sort());
|
|
// The accumulator is consumed so the next version does not re-attribute them.
|
|
expect(await prisma.pagePendingContributor.count({ where: { pageId } })).toBe(0);
|
|
});
|
|
|
|
it('lists versions newest first and renders a version read-only', async () => {
|
|
const pageId = await createPage();
|
|
const first = await versions.createNamed(owner, pageId, { label: 'first' });
|
|
const second = await versions.createNamed(owner, pageId, { label: 'second' });
|
|
|
|
const list = await versions.list(owner, pageId);
|
|
expect(list.map((v) => v.id)).toEqual([second.id, first.id]); // newest first
|
|
|
|
const content = await versions.getContent(owner, pageId, first.id);
|
|
expect(content.id).toBe(first.id);
|
|
expect(typeof content.html).toBe('string');
|
|
expect(typeof content.markdown).toBe('string');
|
|
});
|
|
|
|
// Write-access gating of every history route moved to the route guard
|
|
// with #52 — covered by the permission e2e pack.
|
|
|
|
it('restore returns the target version and emits without mutating history', async () => {
|
|
const pageId = await createPage();
|
|
const version = await versions.createNamed(owner, pageId, { label: 'target' });
|
|
const before = await prisma.pageVersion.count({ where: { pageId } });
|
|
|
|
// The api side only checks permission and signals collab; it must not delete
|
|
// or alter any version (history is append-only — collab appends pre-restore).
|
|
const restored = await versions.restore(owner, pageId, version.id);
|
|
expect(restored.id).toBe(version.id);
|
|
expect(await prisma.pageVersion.count({ where: { pageId } })).toBe(before);
|
|
|
|
await expect(versions.restore(owner, pageId, randomUUID())).rejects.toBeInstanceOf(
|
|
NotFoundException,
|
|
);
|
|
});
|
|
|
|
it('thins auto versions beyond the window to the newest per day, keeping the rest', async () => {
|
|
const pageId = await createPage();
|
|
const daysAgo = (days: number, hour: number): Date => {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() - days);
|
|
d.setHours(hour, 0, 0, 0);
|
|
return d;
|
|
};
|
|
const snapshot = new Uint8Array(Y.encodeStateAsUpdate(new Y.Doc()));
|
|
const auto = (createdAt: Date) => ({
|
|
pageId,
|
|
ydocSnapshot: snapshot,
|
|
trigger: 'AUTO' as const,
|
|
contributorIds: [],
|
|
createdAt,
|
|
});
|
|
|
|
// Two auto versions on one day beyond the window (older + newer), one auto
|
|
// on another day beyond the window, one manual beyond the window, and one
|
|
// auto inside the window.
|
|
const oldDayOlder = daysAgo(120, 8);
|
|
const oldDayNewer = daysAgo(120, 20);
|
|
const otherOldDay = daysAgo(200, 12);
|
|
await prisma.pageVersion.createMany({
|
|
data: [
|
|
auto(oldDayOlder),
|
|
auto(oldDayNewer),
|
|
auto(otherOldDay),
|
|
{ ...auto(daysAgo(150, 10)), trigger: 'MANUAL', label: 'keep me', createdBy: owner.id },
|
|
auto(daysAgo(3, 10)), // within the window
|
|
],
|
|
});
|
|
|
|
const removed = await versions.thinDueVersions();
|
|
expect(removed).toBeGreaterThanOrEqual(1);
|
|
|
|
const remaining = await prisma.pageVersion.findMany({
|
|
where: { pageId },
|
|
orderBy: { createdAt: 'asc' },
|
|
select: { trigger: true, createdAt: true, label: true },
|
|
});
|
|
const times = remaining.map((v) => v.createdAt.getTime());
|
|
// The older of the two same-day beyond-window auto versions is gone…
|
|
expect(times).not.toContain(oldDayOlder.getTime());
|
|
// …its newer same-day sibling survives, as does the other-day one.
|
|
expect(times).toContain(oldDayNewer.getTime());
|
|
expect(times).toContain(otherOldDay.getTime());
|
|
// Manual and within-window auto are always kept.
|
|
expect(remaining.some((v) => v.label === 'keep me')).toBe(true);
|
|
expect(remaining.length).toBe(4);
|
|
expect(VERSION_RETENTION_DAYS).toBe(90);
|
|
});
|
|
});
|