dorfteich/apps/api/src/trash/trash.e2e.db.test.ts
Claude Sonnet 5 a645763679
All checks were successful
CD / Build and push images (push) Successful in 2m5s
CI / Lint, typecheck, test (push) Successful in 1m45s
CI / Auth e2e pack (push) Successful in 1m50s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 10s
Add page trash: soft delete, restore, and purge job (#31)
Backend: a generic maintenance-job scheduler (SchedulerService, `jobs`
table) that any later maintenance job registers with instead of
growing its own timer loop. Due-ness and the run-mutex both live in
the DB row (`lastRunAt` survives a restart; claiming a due job is one
atomic `UPDATE ... WHERE status != 'RUNNING'`), and an injectable
ClockService lets tests simulate retention elapsing without waiting or
faking the global clock.

Trash endpoints: GET /ponds/:id/trash (list), POST /pages/:id/restore,
DELETE /pages/:id/purge (manual, bypasses retention) — all sharing the
same purge logic as the scheduled daily job (default 30-day retention,
new trash.retentionDays instance setting). Purging deletes a page's
content cache, update log, and attachment files/quota; page_versions
is a placeholder until M3 exists. Direct navigation to a trashed page
now 404s with a distinguishable `page_trashed` code for editors (a
plain 404 for everyone else) instead of the generic not-found.

Attachment.pageId — added in #27 but never wired up — now gets set on
every page state save to whichever page's document currently embeds
the file, which is what lets purge find a page's files.

Frontend: a per-pond trash view (restore/purge), a "move to trash"
action with confirmation in the page menu, and a trash link in the
sidebar for pond owners. Also fixes react-query retrying 4xx responses
for several seconds by default, which was masking the trash-hint 404
in the UI (and would have affected any other not-found/permission
error the same way).

Closes #31
2026-07-08 12:48:17 +02:00

281 lines
11 KiB
TypeScript

import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { INestApplication } from '@nestjs/common';
import { editorSchema } from '@dorfteich/shared';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { prosemirrorJSONToYXmlFragment } from 'y-prosemirror';
import * as Y from 'yjs';
import { AuthTokensService } from '../auth/auth-tokens.service';
import { ClockService } from '../common/clock.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
import { SchedulerService } from '../scheduler/scheduler.service';
import { TrashService } from './trash.service';
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const pngBuffer = (payload = 'trash test png'): Buffer =>
Buffer.concat([PNG_SIGNATURE, Buffer.from(payload)]);
/** Encodes a one-paragraph doc embedding `fileId` as an image. */
function stateWithImage(fileId: string): string {
const ydoc = new Y.Doc();
const fragment = ydoc.getXmlFragment('default');
const doc = editorSchema.node('doc', null, [
editorSchema.node('paragraph', null, [
editorSchema.node('image', { fileId, alt: 'trash test', width: null }),
]),
]);
prosemirrorJSONToYXmlFragment(editorSchema, doc.toJSON(), fragment);
const state = Buffer.from(Y.encodeStateAsUpdate(ydoc)).toString('base64');
ydoc.destroy();
return state;
}
describe.skipIf(!hasTestDb)('page trash (e2e, issue #31)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'trash it like its hot 1';
const owner = { username: `tina-trash-${suffix}`, displayName: `Tina Trash ${suffix}` };
const outsider = { username: `otto-trash-${suffix}`, displayName: `Otto Outside ${suffix}` };
let ownerCookie: string;
let outsiderCookie: string;
let pondId: string;
const api = () => request(app.getHttpServer());
async function loginOf(username: string): Promise<string> {
const res = await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200);
return sessionCookieOf(res);
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
const users = app.get(UsersService);
const tokens = app.get(AuthTokensService);
const ownerUser = await users.createUser({
username: owner.username,
email: `${owner.username}@example.org`,
displayName: owner.displayName,
password,
locale: 'en',
});
const verifyToken = await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600);
await api().post('/api/v1/auth/verify-email').send({ token: verifyToken }).expect(204);
ownerCookie = await loginOf(owner.username);
const outsiderUser = await users.createUser({
username: outsider.username,
email: `${outsider.username}@example.org`,
displayName: outsider.displayName,
password,
locale: 'en',
});
await users.markEmailVerified(outsiderUser.id);
outsiderCookie = await loginOf(outsider.username);
const ponds = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200);
pondId = ponds.body.find((p: { type: string }) => p.type === 'personal').id;
});
afterAll(async () => {
await prisma.attachment.deleteMany({ where: { pondId } });
await prisma.page.deleteMany({
where: { pond: { owner: { username: { contains: suffix } } } },
});
const users = await prisma.user.findMany({
where: { username: { contains: suffix } },
select: { id: true },
});
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: users.map((u) => u.id) } } });
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('excludes a soft-deleted page from the sidebar and 404s the direct URL, with a trash hint for editors', async () => {
const created = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Trashed Sidebar ${suffix}` })
.expect(201);
await api().delete(`/api/v1/pages/${created.body.id}`).set('Cookie', ownerCookie).expect(204);
const list = await api()
.get(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.expect(200);
expect((list.body as { id: string }[]).some((p) => p.id === created.body.id)).toBe(false);
const ownerView = await api()
.get(`/api/v1/ponds/${pondId}/pages/${created.body.slug}`)
.set('Cookie', ownerCookie)
.expect(404);
expect(ownerView.body.code).toBe('page_trashed');
expect(ownerView.body.details.pageId).toBe(created.body.id);
const outsiderView = await api()
.get(`/api/v1/ponds/${pondId}/pages/${created.body.slug}`)
.set('Cookie', outsiderCookie)
.expect(404);
expect(outsiderView.body.code).not.toBe('page_trashed');
});
it('lists a pond trash, visible only to editors', async () => {
const created = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Trash List ${suffix}` })
.expect(201);
await api().delete(`/api/v1/pages/${created.body.id}`).set('Cookie', ownerCookie).expect(204);
const trash = await api()
.get(`/api/v1/ponds/${pondId}/trash`)
.set('Cookie', ownerCookie)
.expect(200);
expect((trash.body as { id: string }[]).some((p) => p.id === created.body.id)).toBe(true);
await api().get(`/api/v1/ponds/${pondId}/trash`).set('Cookie', outsiderCookie).expect(404);
});
it('restore brings content and files back intact', async () => {
const created = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Restore Me ${suffix}` })
.expect(201);
const uploaded = await api()
.post(`/api/v1/ponds/${pondId}/files`)
.set('Cookie', ownerCookie)
.attach('file', pngBuffer(), 'restore.png')
.expect(201);
await api()
.put(`/api/v1/pages/${created.body.id}/state`)
.set('Cookie', ownerCookie)
.send({ state: stateWithImage(uploaded.body.id) })
.expect(200);
// The state save links the embedded image to this page (issue #31).
const linked = await prisma.attachment.findUniqueOrThrow({ where: { id: uploaded.body.id } });
expect(linked.pageId).toBe(created.body.id);
await api().delete(`/api/v1/pages/${created.body.id}`).set('Cookie', ownerCookie).expect(204);
await api()
.post(`/api/v1/pages/${created.body.id}/restore`)
.set('Cookie', ownerCookie)
.expect(201);
const restoredPage = await api()
.get(`/api/v1/pages/${created.body.id}`)
.set('Cookie', ownerCookie)
.expect(200);
expect(restoredPage.body.deletedAt).toBeNull();
expect(Buffer.from(restoredPage.body.state, 'base64').length).toBeGreaterThan(0);
await api().get(`/api/v1/media/${uploaded.body.id}`).set('Cookie', ownerCookie).expect(200);
});
it('purges a single page on demand, removing rows and files', async () => {
const created = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Purge Me ${suffix}` })
.expect(201);
const uploaded = await api()
.post(`/api/v1/ponds/${pondId}/files`)
.set('Cookie', ownerCookie)
.attach('file', pngBuffer(), 'purge.png')
.expect(201);
await api()
.put(`/api/v1/pages/${created.body.id}/state`)
.set('Cookie', ownerCookie)
.send({ state: stateWithImage(uploaded.body.id) })
.expect(200);
const filePath = join(process.env.UPLOADS_DIR!, pondId, uploaded.body.id);
expect(existsSync(filePath)).toBe(true);
const usageBefore = await prisma.pondUsage.findUniqueOrThrow({ where: { pondId } });
await api().delete(`/api/v1/pages/${created.body.id}`).set('Cookie', ownerCookie).expect(204);
await api()
.delete(`/api/v1/pages/${created.body.id}/purge`)
.set('Cookie', ownerCookie)
.expect(204);
expect(await prisma.page.findUnique({ where: { id: created.body.id } })).toBeNull();
expect(await prisma.attachment.findUnique({ where: { id: uploaded.body.id } })).toBeNull();
expect(existsSync(filePath)).toBe(false);
const usageAfter = await prisma.pondUsage.findUniqueOrThrow({ where: { pondId } });
expect(Number(usageAfter.storageBytesUsed)).toBeLessThan(Number(usageBefore.storageBytesUsed));
// Purging a page that no longer exists (or was never trashed) 404s.
await api()
.delete(`/api/v1/pages/${created.body.id}/purge`)
.set('Cookie', ownerCookie)
.expect(404);
});
it('the scheduled job purges only pages past retention (time-travel via injected clock)', async () => {
const clock = app.get(ClockService);
const originalNow = clock.now.bind(clock);
const scheduler = app.get(SchedulerService);
const trash = app.get(TrashService);
// Both deleted at real "now" — a few milliseconds apart at most. "old"
// is backdated only 20 days (still within the 30-day default
// retention under real wall-clock time), so the test can prove the
// job's purge/no-purge split comes from the *injected* clock and not
// from how much real time happened to pass while the test ran.
const recent = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Recent Trash ${suffix}` })
.expect(201);
const old = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Old Trash ${suffix}` })
.expect(201);
await api().delete(`/api/v1/pages/${recent.body.id}`).set('Cookie', ownerCookie).expect(204);
await api().delete(`/api/v1/pages/${old.body.id}`).set('Cookie', ownerCookie).expect(204);
const twentyDaysAgo = new Date(originalNow().getTime() - 20 * 24 * 60 * 60 * 1000);
await prisma.page.update({ where: { id: old.body.id }, data: { deletedAt: twentyDaysAgo } });
try {
// Travel 15 days into the future: "old" is now 35 days past its
// deletion (past the 30-day retention), "recent" only 15.
const fifteenDaysLater = new Date(originalNow().getTime() + 15 * 24 * 60 * 60 * 1000);
clock.now = () => fifteenDaysLater;
await scheduler.runIfDue({
name: 'trash-purge-test-run',
cadenceSeconds: 0,
run: () => trash.purgeDuePages(),
});
expect(await prisma.page.findUnique({ where: { id: old.body.id } })).toBeNull();
expect(await prisma.page.findUnique({ where: { id: recent.body.id } })).not.toBeNull();
} finally {
clock.now = originalNow;
await prisma.job.deleteMany({ where: { name: 'trash-purge-test-run' } });
}
});
});