import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; import { ChatChannelType, Role } from '@prisma/client'; import { ChatService } from './chat.service'; import { AuthenticatedUser } from '../auth/authenticated-request'; import { GuestJwtPayload } from '../auth/guest-auth.service'; /// Focus: GRUPPE channel creation + participant management authorization /// (creator / Leitungsteam / Verantwortliche/r of that KC), and read/write /// access for team users and guests. Prisma + Sync + Push faked in memory. function userCaller(userId: string, memberships: AuthenticatedUser['memberships']) { return { kind: 'user' as const, user: { userId, authentikSub: `sub-${userId}`, email: `${userId}@example.org`, memberships }, }; } function guestCaller(guestId: string, kcId: string, gemeindeId: string | null = null) { const guest: GuestJwtPayload = { guestId, kcId, gemeindeId }; return { kind: 'guest' as const, guest }; } const LT = userCaller('lt-1', [{ kcId: 'kc-1', gemeindeId: null, role: Role.LEITUNGSTEAM }]); const VERANTW = userCaller('ver-1', [ { kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER }, ]); const TEAMER = userCaller('teamer-1', [ { kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_TEAMER }, ]); function makeService( opts: { channels?: Record; memberships?: { kcId: string; userId: string }[]; guests?: { id: string; kcId: string }[]; } = {}, ) { const channels: Record = opts.channels ?? {}; const memberships = opts.memberships ?? []; const guests = opts.guests ?? []; const participants: any[] = []; let participantSeq = 0; const prisma = { chatChannel: { create: jest.fn(({ data, include }: any) => { const id = `chan-${Object.keys(channels).length + 1}`; const created = { id, ...data, participants: [] }; if (data.participants?.create) { for (const p of data.participants.create) { const row = { id: `part-${++participantSeq}`, channelId: id, userId: null, guestAccountId: null, ...p }; participants.push(row); created.participants.push(row); } } channels[id] = created; return Promise.resolve(include ? created : { id, ...data }); }), findUnique: jest.fn(({ where, include }: any) => { const channel = channels[where.id]; if (!channel) return Promise.resolve(null); if (include?.participants) { const seeded = participants.filter((p) => p.channelId === channel.id); const fallback = Array.isArray(channel.participants) ? channel.participants : []; return Promise.resolve({ ...channel, participants: seeded.length ? seeded : fallback, }); } return Promise.resolve(channel); }), findMany: jest.fn().mockResolvedValue([]), }, membership: { findMany: jest.fn(({ where }: any) => { const ids: string[] = where.userId.in; const rows = memberships.filter((m) => m.kcId === where.kcId && ids.includes(m.userId)); const seen = new Set(); const distinct = rows.filter((r) => (seen.has(r.userId) ? false : (seen.add(r.userId), true))); return Promise.resolve(distinct); }), findFirst: jest.fn(({ where }: any) => Promise.resolve(memberships.find((m) => m.kcId === where.kcId && m.userId === where.userId) ?? null), ), count: jest.fn().mockResolvedValue(0), }, guestAccount: { count: jest.fn(({ where }: any) => Promise.resolve(guests.filter((g) => where.id.in.includes(g.id) && g.kcId === where.kcId).length), ), findFirst: jest.fn(({ where }: any) => Promise.resolve(guests.find((g) => g.id === where.id && g.kcId === where.kcId) ?? null), ), }, chatParticipant: { upsert: jest.fn(({ create }: any) => { const existing = participants.find( (p) => p.channelId === create.channelId && p.userId === (create.userId ?? null) && p.guestAccountId === (create.guestAccountId ?? null), ); if (existing) return Promise.resolve(existing); const row = { id: `part-${++participantSeq}`, userId: null, guestAccountId: null, ...create }; participants.push(row); return Promise.resolve(row); }), findFirst: jest.fn(({ where }: any) => Promise.resolve( participants.find( (p) => p.channelId === where.channelId && (where.userId === undefined || p.userId === where.userId) && (where.guestAccountId === undefined || p.guestAccountId === where.guestAccountId), ) ?? null, ), ), delete: jest.fn(({ where }: any) => { const idx = participants.findIndex((p) => p.id === where.id); const [removed] = participants.splice(idx, 1); return Promise.resolve(removed); }), }, chatMessage: { create: jest.fn(), findMany: jest.fn() }, }; const sync = { capture: jest.fn().mockResolvedValue(undefined) }; const push = { notifyChannel: jest.fn().mockResolvedValue(undefined) }; const service = new ChatService(prisma as never, sync as never, push as never); return { service, prisma, sync, push, channels, participants }; } describe('ChatService.createGruppe', () => { it('creates a GRUPPE channel with the creator plus given team/guest participants', async () => { const { service, sync } = makeService({ memberships: [{ kcId: 'kc-1', userId: 'ver-1' }, { kcId: 'kc-1', userId: 'teamer-1' }], guests: [{ id: 'guest-1', kcId: 'kc-1' }], }); const channel = await service.createGruppe('kc-1', 'Ausflugsplanung', 'ver-1', { userIds: ['teamer-1'], guestIds: ['guest-1'], }); expect(channel.type).toBe(ChatChannelType.GRUPPE); expect(channel.createdByUserId).toBe('ver-1'); const userIds = channel.participants.map((p: any) => p.userId).filter(Boolean); const guestIds = channel.participants.map((p: any) => p.guestAccountId).filter(Boolean); expect(userIds.sort()).toEqual(['teamer-1', 'ver-1']); expect(guestIds).toEqual(['guest-1']); expect(sync.capture).toHaveBeenCalledWith('ChatChannel', 'CREATE', channel.id, expect.anything()); }); it('does not duplicate the creator if already listed as a participant', async () => { const { service } = makeService({ memberships: [{ kcId: 'kc-1', userId: 'ver-1' }] }); const channel = await service.createGruppe('kc-1', 'X', 'ver-1', { userIds: ['ver-1'] }); const userIds = channel.participants.map((p: any) => p.userId).filter(Boolean); expect(userIds).toEqual(['ver-1']); }); it('rejects a participant who is not a member of the KC', async () => { const { service } = makeService({ memberships: [] }); await expect( service.createGruppe('kc-1', 'X', 'ver-1', { userIds: ['ghost'] }), ).rejects.toBeInstanceOf(BadRequestException); }); it('rejects a guest who is not part of the KC', async () => { const { service } = makeService({ guests: [{ id: 'guest-1', kcId: 'kc-2' }] }); await expect( service.createGruppe('kc-1', 'X', 'ver-1', { guestIds: ['guest-1'] }), ).rejects.toBeInstanceOf(BadRequestException); }); }); describe('ChatService participant management', () => { function seedGruppe() { const channels = { 'chan-1': { id: 'chan-1', kcId: 'kc-1', type: ChatChannelType.GRUPPE, createdByUserId: 'ver-1', gemeindeId: null }, }; return channels; } it('lets the creator add a team user', async () => { const { service } = makeService({ channels: seedGruppe(), memberships: [{ kcId: 'kc-1', userId: 'teamer-1' }], }); const p = await service.addParticipant('chan-1', VERANTW, { userId: 'teamer-1' }); expect(p.userId).toBe('teamer-1'); }); it('lets a Leitungsteam member add a guest even if not the creator', async () => { const { service } = makeService({ channels: seedGruppe(), guests: [{ id: 'guest-1', kcId: 'kc-1' }], }); const p = await service.addParticipant('chan-1', LT, { guestId: 'guest-1' }); expect(p.guestAccountId).toBe('guest-1'); }); it('forbids a plain Teamer (not creator, not LT, not Verantwortliche/r) from managing participants', async () => { const { service } = makeService({ channels: seedGruppe() }); await expect( service.addParticipant('chan-1', TEAMER, { userId: 'teamer-1' }), ).rejects.toBeInstanceOf(ForbiddenException); }); it('forbids guests from managing participants', async () => { const { service } = makeService({ channels: seedGruppe() }); await expect( service.addParticipant('chan-1', guestCaller('g-1', 'kc-1') as never, { userId: 'x' }), ).rejects.toBeInstanceOf(ForbiddenException); }); it('404s for a non-GRUPPE channel', async () => { const channels = { 'chan-2': { id: 'chan-2', kcId: 'kc-1', type: ChatChannelType.GEMEINDE_GRUPPE, createdByUserId: null }, }; const { service } = makeService({ channels }); await expect( service.addParticipant('chan-2', LT, { userId: 'teamer-1' }), ).rejects.toBeInstanceOf(NotFoundException); }); it('rejects adding a user not in the KC', async () => { const { service } = makeService({ channels: seedGruppe(), memberships: [] }); await expect( service.addParticipant('chan-1', LT, { userId: 'ghost' }), ).rejects.toBeInstanceOf(BadRequestException); }); it('removes a participant and is a no-op if already absent', async () => { const { service } = makeService({ channels: seedGruppe(), memberships: [{ kcId: 'kc-1', userId: 'teamer-1' }], }); await service.addParticipant('chan-1', VERANTW, { userId: 'teamer-1' }); const res = await service.removeParticipant('chan-1', VERANTW, { userId: 'teamer-1' }); expect(res).toEqual({ ok: true }); const res2 = await service.removeParticipant('chan-1', VERANTW, { userId: 'teamer-1' }); expect(res2).toEqual({ ok: true }); }); }); describe('ChatService GRUPPE read/write access', () => { function seedGruppeWithParticipants(participants: any[]) { return { 'chan-1': { id: 'chan-1', kcId: 'kc-1', type: ChatChannelType.GRUPPE, createdByUserId: 'ver-1', gemeindeId: null, participants, }, }; } it('lets a listed guest read messages', async () => { const { service } = makeService({ channels: seedGruppeWithParticipants([{ userId: null, guestAccountId: 'guest-1' }]), }); await expect( service.assertCanRead('chan-1', guestCaller('guest-1', 'kc-1') as never), ).resolves.toBeDefined(); }); it('forbids a guest not in the participant list', async () => { const { service } = makeService({ channels: seedGruppeWithParticipants([{ userId: null, guestAccountId: 'guest-1' }]), }); await expect( service.assertCanRead('chan-1', guestCaller('guest-2', 'kc-1') as never), ).rejects.toBeInstanceOf(ForbiddenException); }); it('forbids a team user not in the participant list', async () => { const { service } = makeService({ channels: seedGruppeWithParticipants([{ userId: 'someone-else', guestAccountId: null }]), }); await expect(service.assertCanRead('chan-1', TEAMER)).rejects.toBeInstanceOf(ForbiddenException); }); });