feat: Datenmodell Sprachnotizen (T7 L2 Task 4)

VoiceCapture und VoiceCaptureAudio nach Spec §3 mit CHECKs
(audioBytes 1..2 MiB, durationSec 1..240, CONFIRMED genau mit
inspectionId, audioMime nur bei AUDIO), Recht voice-captures:use für
ADMIN, BEEKEEPER und STAFF, Sperrreihenfolge mit VoiceCapture am Ende,
Ausschluss aus dem Backup mit Begründung, Wächter gegen Suche und
Export.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:20:04 +02:00
1 parent 257286fbef
commit af9df0afe4
10 files changed
+412

No files matched your search

+32
View File
@@ -1,3 +1,5 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
@@ -95,3 +97,33 @@ describe('Search and filters (e2e)', () => {
expect(colonies.map((entry) => entry.id)).toEqual([dissolvedColonyId]);
});
});
describe('Voice capture content guard', () => {
// VoiceCapture holds personal data that is deleted after at most 7 days. It
// must never be reachable through search or any export/report builder.
const sourceRoots = ['search', 'export'].map((dir) =>
path.resolve(__dirname, '../src', dir),
);
function sourceFiles(dir: string): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return sourceFiles(full);
return entry.name.endsWith('.ts') ? [full] : [];
});
}
it('VoiceCapture appears in no search provider and no export', () => {
const files = sourceRoots.flatMap(sourceFiles);
expect(files.length).toBeGreaterThan(0);
for (const file of files) {
expect({
file: path.basename(file),
mentions: /voiceCapture|VoiceCapture/.test(
fs.readFileSync(file, 'utf8'),
),
}).toEqual({ file: path.basename(file), mentions: false });
}
});
});
+134
View File
@@ -0,0 +1,134 @@
import { PrismaService } from '../src/prisma/prisma.service';
/**
* Schema-level guarantees of the T7 migration that the Prisma schema alone
* cannot express (CHECK constraints, cascade) against a real PostgreSQL. The
* test database is shared and not empty: every row here is created by this
* file and removed again.
*/
describe('T7 schema (e2e)', () => {
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const CHECK_VIOLATION = /check constraint|violates/i;
let userId: string;
let apiaryId: string;
let colonyId: string;
let inspectionId: string;
function capture(extra: Record<string, unknown> = {}) {
return prisma.voiceCapture.create({
data: {
ownerUserId: userId,
colonyId,
inspectionDate: new Date('2026-10-16'),
source: 'AUDIO',
contentExpiresAt: new Date('2026-10-23T00:00:00Z'),
...extra,
},
});
}
beforeAll(async () => {
prisma = new PrismaService();
const role = await prisma.role.findUniqueOrThrow({
where: { key: 'READONLY' },
});
const user = await prisma.user.create({
data: {
email: `t7-schema-${suffix}@example.com`,
passwordHash: 'x',
name: 'T7 schema',
roleId: role.id,
},
});
userId = user.id;
const apiary = await prisma.apiary.create({
data: { name: `T7 Stand ${suffix}` },
});
apiaryId = apiary.id;
const colony = await prisma.colony.create({
data: { code: `T7-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY' },
});
colonyId = colony.id;
const inspection = await prisma.inspection.create({
data: { colonyId, userId, date: new Date('2026-10-16') },
});
inspectionId = inspection.id;
});
afterEach(async () => {
await prisma.voiceCapture.deleteMany({ where: { ownerUserId: userId } });
});
afterAll(async () => {
await prisma.voiceCapture.deleteMany({ where: { ownerUserId: userId } });
await prisma.inspection.deleteMany({ where: { id: inspectionId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.user.deleteMany({ where: { id: userId } });
await prisma.$disconnect();
});
it('accepts a plain queued audio capture with defaults', async () => {
const row = await capture({
audioMime: 'audio/webm',
audioBytes: 2097152,
durationSec: 240,
});
expect(row.status).toBe('QUEUED');
expect(row.attempts).toBe(0);
expect(row.claimToken).toBeNull();
});
it('rejects audioBytes 0 and 2097153', async () => {
await expect(capture({ audioBytes: 0 })).rejects.toThrow(CHECK_VIOLATION);
await expect(capture({ audioBytes: 2097153 })).rejects.toThrow(
CHECK_VIOLATION,
);
});
it('rejects durationSec 241', async () => {
await expect(capture({ durationSec: 241 })).rejects.toThrow(
CHECK_VIOLATION,
);
await expect(capture({ durationSec: 0 })).rejects.toThrow(CHECK_VIOLATION);
});
it('rejects CONFIRMED without inspectionId and inspectionId on a non-confirmed row', async () => {
await expect(capture({ status: 'CONFIRMED' })).rejects.toThrow(
CHECK_VIOLATION,
);
await expect(capture({ status: 'READY', inspectionId })).rejects.toThrow(
CHECK_VIOLATION,
);
const confirmed = await capture({ status: 'CONFIRMED', inspectionId });
expect(confirmed.inspectionId).toBe(inspectionId);
});
it('rejects audioMime on a TEXT capture', async () => {
await expect(
capture({ source: 'TEXT', audioMime: 'audio/webm' }),
).rejects.toThrow(CHECK_VIOLATION);
const text = await capture({ source: 'TEXT' });
expect(text.audioMime).toBeNull();
});
it('deleting a capture cascades to its audio row', async () => {
const row = await capture({
audioMime: 'audio/webm',
audioBytes: 3,
audio: { create: { dataEnc: Buffer.from([1, 2, 3]) } },
});
expect(
await prisma.voiceCaptureAudio.count({ where: { captureId: row.id } }),
).toBe(1);
await prisma.voiceCapture.delete({ where: { id: row.id } });
expect(
await prisma.voiceCaptureAudio.count({ where: { captureId: row.id } }),
).toBe(0);
});
});
@@ -0,0 +1,56 @@
import { PERMISSION_CATALOG } from '../src/auth/permissions/permission-catalog';
import { PrismaService } from '../src/prisma/prisma.service';
const KEY = 'voice-captures:use';
/**
* The migration grants the T7 permission to the system roles (the boot-time
* catalog sync only grants new permissions to ADMIN). Checked against the
* migrated test database.
*/
describe('T7 voice permissions (e2e)', () => {
let prisma: PrismaService;
beforeAll(() => {
prisma = new PrismaService();
});
afterAll(async () => {
await prisma.$disconnect();
});
async function holds(roleKey: string): Promise<boolean> {
const grant = await prisma.rolePermission.findFirst({
where: { role: { key: roleKey }, permission: { key: KEY } },
});
return grant !== null;
}
it('ADMIN, BEEKEEPER and STAFF hold voice-captures:use', async () => {
expect(await holds('ADMIN')).toBe(true);
expect(await holds('BEEKEEPER')).toBe(true);
expect(await holds('STAFF')).toBe(true);
});
it('READONLY and API_CLIENT do not', async () => {
expect(await holds('READONLY')).toBe(false);
expect(await holds('API_CLIENT')).toBe(false);
});
it('matches the code catalog in the Sprache group at sortOrder 4000', async () => {
const entry = PERMISSION_CATALOG.find((e) => e.key === KEY);
const row = await prisma.permission.findUniqueOrThrow({
where: { key: KEY },
});
expect(entry).toBeDefined();
expect(row).toMatchObject({
resource: 'voice-captures',
action: 'use',
groupDe: 'Sprache',
labelDe: 'Benutzen',
sortOrder: 4000,
});
expect(entry).toMatchObject({ groupDe: 'Sprache', sortOrder: 4000 });
});
});