import { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { ClockService } from '../common/clock.service'; import { InstanceSettingsService } from '../settings/instance-settings.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); /** * The read-trail dedup window (issue #223, ADR 0023): one event per * (session, page, channel) within an aligned `readTrail.dedupWindowMinutes` * window — repeats and reconnect-style re-requests collapse, a new session * or a new window does not, and the recorded row states the window length * it represents. */ describe.skipIf(!hasTestDb)('read-trail dedup window (e2e, issue #223)', () => { let app: INestApplication; let prisma: PrismaClient; const suffix = uniqueSuffix(); const password = 'dedup fenster zeugen 123'; let ownerId: string; let ownerCookie: string; let pondId: string; let classifiedId: string; const api = () => request(app.getHttpServer()); const login = async () => sessionCookieOf( await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: `dedup-owner-${suffix}`, password }) .expect(200), ); const events = () => prisma.readEvent.findMany({ where: { pondId }, orderBy: { occurredAt: 'asc' } }); beforeAll(async () => { prisma = createTestPrisma(); await prisma.rateLimit.deleteMany({}); app = await createTestApp(); const users = app.get(UsersService); const owner = await users.createUser({ username: `dedup-owner-${suffix}`, email: `dedup-owner-${suffix}@example.test`, displayName: 'Dedup Owner', password, locale: 'en', }); await users.markEmailVerified(owner.id); ownerId = owner.id; // The trail ships OFF by default (#225) — this suite needs it on. await app.get(InstanceSettingsService).set('readTrail.enabled', true, ownerId); ownerCookie = await login(); const pond = await prisma.pond.create({ data: { slug: `dedup-pond-${suffix}`, name: 'Dedup Pond', type: 'SHARED', ownerId }, }); pondId = pond.id; await grantOwnerAdmin(prisma, pondId, ownerId); const page = await prisma.page.create({ data: { pondId, slug: `classified-${suffix}`, title: 'Classified', classification: 'VS_NFD', createdBy: ownerId, sortKey: 'a0', ydocState: new Uint8Array(), contentCache: { create: { plainText: 'x', markdown: 'x', html: '
x
', outline: [] }, }, }, }); classifiedId = page.id; }); afterEach(async () => { await prisma.readEvent.deleteMany({ where: { pondId } }); await prisma.rateLimit.deleteMany({}); }); afterAll(async () => { await prisma.instanceSetting.deleteMany({ where: { key: 'readTrail.enabled' } }); await prisma.readEvent.deleteMany({ where: { pondId } }); await prisma.attachment.deleteMany({ where: { pondId } }); await prisma.roleGrant.deleteMany({ where: { pondId } }); await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } }); await prisma.page.deleteMany({ where: { pondId } }); await prisma.pond.deleteMany({ where: { id: pondId } }); await prisma.user.deleteMany({ where: { id: ownerId } }); await prisma.$disconnect(); await app.close(); }); it('collapses repeated reads of one page in one session+channel to a single event that names its window', async () => { await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200); await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200); await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200); const rows = await events(); expect(rows).toHaveLength(1); // The row itself states that it represents a window, not a request. expect(rows[0]!.windowSeconds).toBe(5 * 60); expect(rows[0]!.dedupKey).toBe(`${rows[0]!.sessionKey}:${classifiedId}:page_view`); }); it('keeps channels apart: the same session reading and joining collab yields one event each', async () => { await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200); await api() .get(`/api/v1/pages/${classifiedId}/collab-token`) .set('Cookie', ownerCookie) .expect(200); const rows = await events(); expect(rows.map((r) => r.channel).sort()).toEqual(['collab_join', 'page_view']); }); it('a new session records again even for the same user — a reconnect within the session does not', async () => { await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200); // Same session, later request ("reconnect"): deduped. await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200); expect(await events()).toHaveLength(1); const secondCookie = await login(); await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', secondCookie).expect(200); const rows = await events(); expect(rows).toHaveLength(2); expect(new Set(rows.map((r) => r.sessionKey)).size).toBe(2); expect(rows.every((r) => r.actorId === ownerId)).toBe(true); }); it('bounds a live editing session: 30 collab-token renewals in one window are one event', async () => { for (let i = 0; i < 30; i += 1) { await api() .get(`/api/v1/pages/${classifiedId}/collab-token`) .set('Cookie', ownerCookie) .expect(200); } const rows = await events(); expect(rows).toHaveLength(1); expect(rows[0]!.channel).toBe('collab_join'); }); it('opens a new window when the clock moves past the bucket boundary', async () => { await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200); const clock = app.get(ClockService); const later = new Date(Date.now() + 10 * 60 * 1000); const spy = vi.spyOn(clock, 'now').mockReturnValue(later); try { await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200); } finally { spy.mockRestore(); } expect(await events()).toHaveLength(2); }); it('dedups page-less attachment downloads on the placeholder page key', async () => { // A pond-level upload has no page; its effective classification falls // back to the pond maximum (#212) and the dedup key carries `-`. const uploaded = await api() .post(`/api/v1/ponds/${pondId}/files`) .set('Cookie', ownerCookie) .attach('file', Buffer.concat([PNG_SIGNATURE, Buffer.from('img')]), 'a.png') .expect(201); await prisma.readEvent.deleteMany({ where: { pondId } }); await api().get(`/api/v1/media/${uploaded.body.id}`).set('Cookie', ownerCookie).expect(200); await api().get(`/api/v1/media/${uploaded.body.id}`).set('Cookie', ownerCookie).expect(200); const rows = await events(); expect(rows).toHaveLength(1); expect(rows[0]!.channel).toBe('attachment'); expect(rows[0]!.pageId).toBeNull(); expect(rows[0]!.dedupKey).toBe(`${rows[0]!.sessionKey}:-:attachment`); }); });