Opening a pond landed on whatever sorted first in the sidebar — stable, but a rule nobody could see, and one whose target moved as soon as someone added a page ahead of it. New ponds landed on the empty-pond hint instead of anything useful. - `startPageId` joins the pond settings. No migration: `Pond.settings` is already jsonb. It stores an id, not a slug, so renaming or moving the page keeps it working. - `PondHomePage` prefers it, but only when the page is in this user's page list. That list already holds just what they may see, so a start page hidden by a page-scoped grant — or trashed — falls back silently instead of landing them on a 404, and it costs no extra request. - Both creation paths give the pond a start page, titled from the creator's stored locale. It happens after the creating transaction commits: the owner's grant is written inside it and permissions cache per pond, so creating the page any earlier would ask about rights the grant has not published yet. A failure is logged, not fatal — a pond without a start page still works. `PagesModule` imported `PondsModule` without using it. Removing that vestigial edge let PondsModule depend on PagesModule in the honest direction instead of tying the two together with forwardRef. Every pond created through the api now owns a page, which broke eight suites whose teardown deleted ponds directly — `Page.pond` deliberately has no cascade, because a real purge removes contents explicitly and audits it. A shared `deletePondsWhere` helper deletes pages first. Two tests that counted pages now account for the start page rather than pretending the pond began empty.
399 lines
15 KiB
TypeScript
399 lines
15 KiB
TypeScript
import { INestApplication } from '@nestjs/common';
|
|
import { PrismaClient } from '@prisma/client';
|
|
import request from 'supertest';
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { AuthTokensService } from '../auth/auth-tokens.service';
|
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
|
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
import { UsersService } from '../users/users.service';
|
|
import { PondAccessNotifier } from './pond-access-notifier.service';
|
|
|
|
describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => {
|
|
let app: INestApplication;
|
|
let prisma: PrismaClient;
|
|
const suffix = uniqueSuffix();
|
|
const password = 'teichbesitz mit stil 1';
|
|
|
|
const owner = { username: `pia-${suffix}`, displayName: `Pia Pond ${suffix}` };
|
|
const outsider = { username: `otto-${suffix}`, displayName: `Otto Outside ${suffix}` };
|
|
let ownerCookie: string;
|
|
let outsiderCookie: string;
|
|
let adminCookie: 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);
|
|
|
|
// The owner goes through the real verify flow — that is what must
|
|
// produce the personal pond.
|
|
const ownerUser = await users.createUser({
|
|
username: owner.username,
|
|
email: `${owner.username}@example.org`,
|
|
displayName: owner.displayName,
|
|
password,
|
|
locale: 'de',
|
|
});
|
|
const verifyToken = await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600);
|
|
await api().post('/api/v1/auth/verify-email').send({ token: verifyToken }).expect(204);
|
|
// additional_ponds defaults to 0 (ADR 0011) — the shared-pond tests
|
|
// need headroom; the outsider stays at the default for the quota test.
|
|
await prisma.quotaOverride.create({
|
|
data: {
|
|
subjectType: 'USER',
|
|
subjectId: ownerUser.id,
|
|
quotaKey: 'additional_ponds',
|
|
value: 100,
|
|
},
|
|
});
|
|
ownerCookie = await loginOf(owner.username);
|
|
|
|
// The outsider doubles as Site Admin in the trash/restore tests.
|
|
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);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
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 deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
|
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
|
await prisma.$disconnect();
|
|
await app.close();
|
|
});
|
|
|
|
it('creates the personal pond on e-mail verification', async () => {
|
|
const res = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200);
|
|
const personal = res.body.filter((p: { type: string }) => p.type === 'personal');
|
|
expect(personal).toHaveLength(1);
|
|
expect(personal[0].name).toBe(owner.displayName);
|
|
expect(personal[0].slug).toMatch(/^pia-pond-/);
|
|
expect(personal[0].settings.sidebarSort).toBe('alpha');
|
|
expect(personal[0].settings.fonts.body.family).toBe('Roboto');
|
|
});
|
|
|
|
it('re-verification does not duplicate the personal pond', async () => {
|
|
const tokens = app.get(AuthTokensService);
|
|
const user = await prisma.user.findUniqueOrThrow({ where: { username: owner.username } });
|
|
const token = await tokens.issue(user.id, 'EMAIL_VERIFICATION', 600);
|
|
await api().post('/api/v1/auth/verify-email').send({ token }).expect(204);
|
|
const res = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200);
|
|
expect(res.body.filter((p: { type: string }) => p.type === 'personal')).toHaveLength(1);
|
|
});
|
|
|
|
it('gives a new shared pond a start page and points settings at it (issue #302)', async () => {
|
|
const created = await api()
|
|
.post('/api/v1/ponds')
|
|
.set('Cookie', ownerCookie)
|
|
.send({ name: `Startseitenteich ${suffix}` })
|
|
.expect(201);
|
|
|
|
const pages = await api()
|
|
.get(`/api/v1/ponds/${created.body.id}/pages`)
|
|
.set('Cookie', ownerCookie)
|
|
.expect(200);
|
|
expect(pages.body).toHaveLength(1);
|
|
|
|
// The title follows the creator's stored locale — this owner is 'de'.
|
|
expect(pages.body[0].title).toBe('Startseite');
|
|
|
|
const pond = await api()
|
|
.get(`/api/v1/ponds/${created.body.slug}`)
|
|
.set('Cookie', ownerCookie)
|
|
.expect(200);
|
|
expect(pond.body.settings.startPageId).toBe(pages.body[0].id);
|
|
});
|
|
|
|
it("titles the start page in the creator's locale (issue #302)", async () => {
|
|
// The owner is 'de' and got "Startseite" above; an 'en' account must get
|
|
// the English title. Without both halves the test would pass on a
|
|
// hardcoded string just as happily.
|
|
const users = app.get(UsersService);
|
|
const tokens = app.get(AuthTokensService);
|
|
const username = `ellie-${suffix}`;
|
|
const user = await users.createUser({
|
|
username,
|
|
email: `${username}@example.org`,
|
|
displayName: `Ellie English ${suffix}`,
|
|
password,
|
|
locale: 'en',
|
|
});
|
|
const token = await tokens.issue(user.id, 'EMAIL_VERIFICATION', 600);
|
|
await api().post('/api/v1/auth/verify-email').send({ token }).expect(204);
|
|
|
|
const pond = await prisma.pond.findFirstOrThrow({
|
|
where: { ownerId: user.id, type: 'PERSONAL' },
|
|
});
|
|
const pages = await prisma.page.findMany({ where: { pondId: pond.id } });
|
|
expect(pages.map((page) => page.title)).toEqual(['Home']);
|
|
});
|
|
|
|
it('gives the personal pond a start page too (issue #302)', async () => {
|
|
const res = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200);
|
|
const personal = res.body.find((p: { type: string }) => p.type === 'personal');
|
|
const pages = await api()
|
|
.get(`/api/v1/ponds/${personal.id}/pages`)
|
|
.set('Cookie', ownerCookie)
|
|
.expect(200);
|
|
expect(pages.body).toHaveLength(1);
|
|
expect(personal.settings.startPageId).toBe(pages.body[0].id);
|
|
});
|
|
|
|
it('changes the start page without losing other settings (issue #302)', async () => {
|
|
const created = await api()
|
|
.post('/api/v1/ponds')
|
|
.set('Cookie', ownerCookie)
|
|
.send({ name: `Wechselteich ${suffix}` })
|
|
.expect(201);
|
|
await api()
|
|
.patch(`/api/v1/ponds/${created.body.id}`)
|
|
.set('Cookie', ownerCookie)
|
|
.send({ commentPolicy: 'editors' })
|
|
.expect(200);
|
|
|
|
const second = await api()
|
|
.post(`/api/v1/ponds/${created.body.id}/pages`)
|
|
.set('Cookie', ownerCookie)
|
|
.send({ title: `Zweite ${suffix}` })
|
|
.expect(201);
|
|
const updated = await api()
|
|
.patch(`/api/v1/ponds/${created.body.id}`)
|
|
.set('Cookie', ownerCookie)
|
|
.send({ startPageId: second.body.id })
|
|
.expect(200);
|
|
|
|
expect(updated.body.settings.startPageId).toBe(second.body.id);
|
|
// The neighbouring key must survive the merge — settings hold only
|
|
// deviations, so an assigning write would silently reset it.
|
|
expect(updated.body.settings.commentPolicy).toBe('editors');
|
|
});
|
|
|
|
it('clears the start page back to the sort-order default (issue #302)', async () => {
|
|
const created = await api()
|
|
.post('/api/v1/ponds')
|
|
.set('Cookie', ownerCookie)
|
|
.send({ name: `Leerteich ${suffix}` })
|
|
.expect(201);
|
|
const cleared = await api()
|
|
.patch(`/api/v1/ponds/${created.body.id}`)
|
|
.set('Cookie', ownerCookie)
|
|
.send({ startPageId: null })
|
|
.expect(200);
|
|
expect(cleared.body.settings.startPageId).toBeNull();
|
|
});
|
|
|
|
it('creates shared ponds with deterministic slug suffixes', async () => {
|
|
const name = `Gartenteich ${suffix}`;
|
|
const first = await api()
|
|
.post('/api/v1/ponds')
|
|
.set('Cookie', ownerCookie)
|
|
.send({ name })
|
|
.expect(201);
|
|
const second = await api()
|
|
.post('/api/v1/ponds')
|
|
.set('Cookie', ownerCookie)
|
|
.send({ name })
|
|
.expect(201);
|
|
expect(first.body.slug).toBe(`gartenteich-${suffix}`);
|
|
expect(second.body.slug).toBe(`gartenteich-${suffix}-2`);
|
|
expect(first.body.type).toBe('shared');
|
|
});
|
|
|
|
it('renames a pond without changing its slug', async () => {
|
|
const created = await api()
|
|
.post('/api/v1/ponds')
|
|
.set('Cookie', ownerCookie)
|
|
.send({ name: `Umbenannt ${suffix}`, description: 'vorher' })
|
|
.expect(201);
|
|
const patched = await api()
|
|
.patch(`/api/v1/ponds/${created.body.id}`)
|
|
.set('Cookie', ownerCookie)
|
|
.send({ name: `Neuer Name ${suffix}`, description: 'nachher', sidebarSort: 'created' })
|
|
.expect(200);
|
|
expect(patched.body.name).toBe(`Neuer Name ${suffix}`);
|
|
expect(patched.body.slug).toBe(created.body.slug);
|
|
expect(patched.body.description).toBe('nachher');
|
|
expect(patched.body.settings.sidebarSort).toBe('created');
|
|
|
|
const fetched = await api()
|
|
.get(`/api/v1/ponds/${created.body.slug}`)
|
|
.set('Cookie', ownerCookie)
|
|
.expect(200);
|
|
expect(fetched.body.name).toBe(`Neuer Name ${suffix}`);
|
|
});
|
|
|
|
it('saves the pond accent theme without losing other settings (issue #186)', async () => {
|
|
const created = await api()
|
|
.post('/api/v1/ponds')
|
|
.set('Cookie', ownerCookie)
|
|
.send({ name: `Akzent ${suffix}` })
|
|
.expect(201);
|
|
|
|
// First persist a non-default font, then the theme — the settings
|
|
// merge must keep both (the jsonb stores only deviations).
|
|
await api()
|
|
.patch(`/api/v1/ponds/${created.body.id}`)
|
|
.set('Cookie', ownerCookie)
|
|
.send({
|
|
fonts: { ...created.body.settings.fonts, heading: { family: 'Roboto', weight: 700 } },
|
|
})
|
|
.expect(200);
|
|
const themed = await api()
|
|
.patch(`/api/v1/ponds/${created.body.id}`)
|
|
.set('Cookie', ownerCookie)
|
|
.send({ theme: { accent: '#2b5f8f' } })
|
|
.expect(200);
|
|
expect(themed.body.settings.theme.accent).toBe('#2b5f8f');
|
|
expect(themed.body.settings.fonts.heading.weight).toBe(700);
|
|
|
|
// Back to inherit; malformed hexes never reach the settings.
|
|
const inherit = await api()
|
|
.patch(`/api/v1/ponds/${created.body.id}`)
|
|
.set('Cookie', ownerCookie)
|
|
.send({ theme: { accent: null } })
|
|
.expect(200);
|
|
expect(inherit.body.settings.theme.accent).toBeNull();
|
|
await api()
|
|
.patch(`/api/v1/ponds/${created.body.id}`)
|
|
.set('Cookie', ownerCookie)
|
|
.send({ theme: { accent: 'lila' } })
|
|
.expect(400);
|
|
});
|
|
|
|
it('hides foreign ponds (list and slug lookup)', async () => {
|
|
const created = await api()
|
|
.post('/api/v1/ponds')
|
|
.set('Cookie', ownerCookie)
|
|
.send({ name: `Privatteich ${suffix}` })
|
|
.expect(201);
|
|
const list = await api().get('/api/v1/ponds').set('Cookie', outsiderCookie).expect(200);
|
|
expect(list.body.map((p: { id: string }) => p.id)).not.toContain(created.body.id);
|
|
await api().get(`/api/v1/ponds/${created.body.slug}`).set('Cookie', outsiderCookie).expect(404);
|
|
await api()
|
|
.patch(`/api/v1/ponds/${created.body.id}`)
|
|
.set('Cookie', outsiderCookie)
|
|
.send({ name: 'gekapert' })
|
|
.expect(404);
|
|
});
|
|
|
|
it('enforces the additional_ponds quota (default 0, override wins)', async () => {
|
|
const res = await api()
|
|
.post('/api/v1/ponds')
|
|
.set('Cookie', outsiderCookie)
|
|
.send({ name: `Quotateich ${suffix}` })
|
|
.expect(403);
|
|
expect(res.body.code).toBe('quota_exceeded');
|
|
expect(res.body.details).toMatchObject({ quotaKey: 'additional_ponds', limit: 0 });
|
|
|
|
const outsiderUser = await prisma.user.findUniqueOrThrow({
|
|
where: { username: outsider.username },
|
|
});
|
|
await prisma.quotaOverride.create({
|
|
data: {
|
|
subjectType: 'USER',
|
|
subjectId: outsiderUser.id,
|
|
quotaKey: 'additional_ponds',
|
|
value: 1,
|
|
},
|
|
});
|
|
await api()
|
|
.post('/api/v1/ponds')
|
|
.set('Cookie', outsiderCookie)
|
|
.send({ name: `Quotateich ${suffix}` })
|
|
.expect(201);
|
|
const second = await api()
|
|
.post('/api/v1/ponds')
|
|
.set('Cookie', outsiderCookie)
|
|
.send({ name: `Quotateich zwei ${suffix}` })
|
|
.expect(403);
|
|
expect(second.body.details.limit).toBe(1);
|
|
});
|
|
|
|
it('soft-deletes a shared pond; Site Admin sees trash and restores', async () => {
|
|
const created = await api()
|
|
.post('/api/v1/ponds')
|
|
.set('Cookie', ownerCookie)
|
|
.send({ name: `Wegwerfteich ${suffix}` })
|
|
.expect(201);
|
|
await api().delete(`/api/v1/ponds/${created.body.id}`).set('Cookie', ownerCookie).expect(204);
|
|
|
|
const list = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200);
|
|
expect(list.body.map((p: { id: string }) => p.id)).not.toContain(created.body.id);
|
|
await api().get(`/api/v1/ponds/${created.body.slug}`).set('Cookie', ownerCookie).expect(404);
|
|
|
|
// Trash endpoints are Site-Admin-only.
|
|
await api().get('/api/v1/ponds/trash').set('Cookie', ownerCookie).expect(403);
|
|
await prisma.user.update({
|
|
where: { username: outsider.username },
|
|
data: { isSiteAdmin: true },
|
|
});
|
|
adminCookie = await loginOf(outsider.username);
|
|
const trash = await api().get('/api/v1/ponds/trash').set('Cookie', adminCookie).expect(200);
|
|
expect(trash.body.map((p: { id: string }) => p.id)).toContain(created.body.id);
|
|
|
|
await api()
|
|
.post(`/api/v1/ponds/${created.body.id}/restore`)
|
|
.set('Cookie', adminCookie)
|
|
.expect(201);
|
|
const restored = await api()
|
|
.get(`/api/v1/ponds/${created.body.slug}`)
|
|
.set('Cookie', ownerCookie)
|
|
.expect(200);
|
|
expect(restored.body.deletedAt).toBeNull();
|
|
});
|
|
|
|
it('notifies the collab server when a pond is trashed (issue #39)', async () => {
|
|
// The api emits the generic pond-access-change signal on soft-delete; the
|
|
// collab server listens and terminates the affected live sessions. Here we
|
|
// assert the api side fires it with the pond id; the NOTIFY→close delivery
|
|
// is proven end-to-end in the collab access-listener DB test.
|
|
const notifier = app.get(PondAccessNotifier);
|
|
const spy = vi.spyOn(notifier, 'notifyAccessChanged');
|
|
const created = await api()
|
|
.post('/api/v1/ponds')
|
|
.set('Cookie', ownerCookie)
|
|
.send({ name: `Signalteich ${suffix}` })
|
|
.expect(201);
|
|
|
|
await api().delete(`/api/v1/ponds/${created.body.id}`).set('Cookie', ownerCookie).expect(204);
|
|
|
|
expect(spy).toHaveBeenCalledWith(created.body.id);
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it('refuses to delete the personal pond', async () => {
|
|
const list = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200);
|
|
const personal = list.body.find((p: { type: string }) => p.type === 'personal');
|
|
const res = await api()
|
|
.delete(`/api/v1/ponds/${personal.id}`)
|
|
.set('Cookie', ownerCookie)
|
|
.expect(403);
|
|
expect(res.body.code).toBe('personal_pond_undeletable');
|
|
});
|
|
});
|