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:
1 parent
257286fbef
commit
af9df0afe4
10 files changed
+412
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user