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,96 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "VoiceCaptureSource" AS ENUM ('AUDIO', 'TEXT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "VoiceCaptureStatus" AS ENUM ('QUEUED', 'TRANSCRIBING', 'TRANSCRIBED', 'EXTRACTING', 'READY', 'CONFIRMED', 'DISCARDED', 'FAILED', 'EXPIRED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VoiceCapture" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"ownerUserId" TEXT NOT NULL,
|
||||||
|
"colonyId" TEXT NOT NULL,
|
||||||
|
"inspectionDate" DATE NOT NULL,
|
||||||
|
"source" "VoiceCaptureSource" NOT NULL,
|
||||||
|
"status" "VoiceCaptureStatus" NOT NULL DEFAULT 'QUEUED',
|
||||||
|
"audioMime" TEXT,
|
||||||
|
"audioBytes" INTEGER,
|
||||||
|
"durationSec" INTEGER,
|
||||||
|
"transcriptEnc" TEXT,
|
||||||
|
"suggestionEnc" TEXT,
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"claimedAt" TIMESTAMP(3),
|
||||||
|
"claimToken" TEXT,
|
||||||
|
"lastErrorCode" TEXT,
|
||||||
|
"model" TEXT,
|
||||||
|
"inputTokens" INTEGER,
|
||||||
|
"outputTokens" INTEGER,
|
||||||
|
"inspectionId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"readyAt" TIMESTAMP(3),
|
||||||
|
"contentExpiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"contentDeletedAt" TIMESTAMP(3),
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "VoiceCapture_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VoiceCaptureAudio" (
|
||||||
|
"captureId" TEXT NOT NULL,
|
||||||
|
"dataEnc" BYTEA NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "VoiceCaptureAudio_pkey" PRIMARY KEY ("captureId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VoiceCapture_status_availableAt_idx" ON "VoiceCapture"("status", "availableAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VoiceCapture_ownerUserId_status_idx" ON "VoiceCapture"("ownerUserId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VoiceCapture_contentExpiresAt_idx" ON "VoiceCapture"("contentExpiresAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VoiceCapture" ADD CONSTRAINT "VoiceCapture_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VoiceCapture" ADD CONSTRAINT "VoiceCapture_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VoiceCaptureAudio" ADD CONSTRAINT "VoiceCaptureAudio_captureId_fkey" FOREIGN KEY ("captureId") REFERENCES "VoiceCapture"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
-- Constraints Prisma cannot express.
|
||||||
|
ALTER TABLE "VoiceCapture"
|
||||||
|
ADD CONSTRAINT "VoiceCapture_audioBytes_range"
|
||||||
|
CHECK ("audioBytes" IS NULL OR "audioBytes" BETWEEN 1 AND 2097152);
|
||||||
|
|
||||||
|
ALTER TABLE "VoiceCapture"
|
||||||
|
ADD CONSTRAINT "VoiceCapture_durationSec_range"
|
||||||
|
CHECK ("durationSec" IS NULL OR "durationSec" BETWEEN 1 AND 240);
|
||||||
|
|
||||||
|
ALTER TABLE "VoiceCapture"
|
||||||
|
ADD CONSTRAINT "VoiceCapture_confirmed_matches_inspection"
|
||||||
|
CHECK (("status" = 'CONFIRMED') = ("inspectionId" IS NOT NULL));
|
||||||
|
|
||||||
|
ALTER TABLE "VoiceCapture"
|
||||||
|
ADD CONSTRAINT "VoiceCapture_audioMime_only_for_audio"
|
||||||
|
CHECK ("source" = 'AUDIO' OR "audioMime" IS NULL);
|
||||||
|
|
||||||
|
-- Permission: the boot-time catalog sync only grants new permissions to
|
||||||
|
-- system roles (ADMIN), so the grants are made here (same as T6). READONLY
|
||||||
|
-- and API_CLIENT get nothing: voice notes are for people who inspect.
|
||||||
|
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'voice-captures:use', 'voice-captures', 'use', 'Benutzen', 'Sprache', 4000)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT r.id, p.id, now()
|
||||||
|
FROM "Role" r, "Permission" p
|
||||||
|
WHERE p.key = 'voice-captures:use'
|
||||||
|
AND r.key IN ('ADMIN', 'BEEKEEPER', 'STAFF')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -104,6 +104,7 @@ model User {
|
|||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
|
|
||||||
inspections Inspection[]
|
inspections Inspection[]
|
||||||
|
voiceCaptures VoiceCapture[]
|
||||||
hiveChanges HiveChange[]
|
hiveChanges HiveChange[]
|
||||||
varroaMeasurements VarroaMeasurement[]
|
varroaMeasurements VarroaMeasurement[]
|
||||||
swarmControlChecks SwarmControlCheck[]
|
swarmControlChecks SwarmControlCheck[]
|
||||||
@@ -333,6 +334,7 @@ model Colony {
|
|||||||
queenEvaluations QueenEvaluation[]
|
queenEvaluations QueenEvaluation[]
|
||||||
suggestions Suggestion[]
|
suggestions Suggestion[]
|
||||||
hiveSetup HiveSetup?
|
hiveSetup HiveSetup?
|
||||||
|
voiceCaptures VoiceCapture[]
|
||||||
|
|
||||||
@@index([apiaryId])
|
@@index([apiaryId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -2285,3 +2287,73 @@ enum HiveChangeSource {
|
|||||||
INSPECTION
|
INSPECTION
|
||||||
AI_SUGGESTION
|
AI_SUGGESTION
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum VoiceCaptureSource {
|
||||||
|
AUDIO
|
||||||
|
TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VoiceCaptureStatus {
|
||||||
|
QUEUED // audio uploaded, waits for Whisper
|
||||||
|
TRANSCRIBING // claimed by the CronJob
|
||||||
|
TRANSCRIBED // text present, waits for Claude (also source TEXT)
|
||||||
|
EXTRACTING // claimed by the worker
|
||||||
|
READY // suggestions present, waits for the user
|
||||||
|
CONFIRMED // inspection saved, content deleted
|
||||||
|
DISCARDED // discarded by the user, content deleted
|
||||||
|
FAILED // finally failed, code in lastErrorCode
|
||||||
|
EXPIRED // deadline passed, content deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
// A voice note. Audio and text are personal data: content lives here only
|
||||||
|
// encrypted and only until it is taken over, at most 7 days. The row itself
|
||||||
|
// holds no content and stays 90 days for the error and cost overview.
|
||||||
|
model VoiceCapture {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
ownerUserId String
|
||||||
|
owner User @relation(fields: [ownerUserId], references: [id])
|
||||||
|
colonyId String
|
||||||
|
colony Colony @relation(fields: [colonyId], references: [id])
|
||||||
|
inspectionDate DateTime @db.Date // date of the inspection per the form
|
||||||
|
source VoiceCaptureSource
|
||||||
|
status VoiceCaptureStatus @default(QUEUED)
|
||||||
|
|
||||||
|
audioMime String? // audio/webm, audio/ogg, audio/mp4
|
||||||
|
audioBytes Int?
|
||||||
|
durationSec Int? // reported by the device; Whisper measures again
|
||||||
|
|
||||||
|
transcriptEnc String? // key-ring ciphertext
|
||||||
|
suggestionEnc String? // key-ring ciphertext of the checked suggestion
|
||||||
|
|
||||||
|
attempts Int @default(0)
|
||||||
|
availableAt DateTime @default(now())
|
||||||
|
claimedAt DateTime?
|
||||||
|
claimToken String? // guards against completion by a process that lost the claim
|
||||||
|
lastErrorCode String? // fixed code, never content
|
||||||
|
|
||||||
|
model String? // e.g. "claude-sonnet-5-5"
|
||||||
|
inputTokens Int?
|
||||||
|
outputTokens Int?
|
||||||
|
|
||||||
|
inspectionId String? // set when CONFIRMED
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
readyAt DateTime?
|
||||||
|
contentExpiresAt DateTime // createdAt + 7 days
|
||||||
|
contentDeletedAt DateTime? // when audio/text/suggestion were deleted
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
audio VoiceCaptureAudio?
|
||||||
|
|
||||||
|
@@index([status, availableAt])
|
||||||
|
@@index([ownerUserId, status])
|
||||||
|
@@index([contentExpiresAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// The audio lives in its own table: deleting is a DELETE of the row (not
|
||||||
|
// nulling a big column), and the row is never loaded into lists.
|
||||||
|
model VoiceCaptureAudio {
|
||||||
|
captureId String @id
|
||||||
|
capture VoiceCapture @relation(fields: [captureId], references: [id], onDelete: Cascade)
|
||||||
|
dataEnc Bytes // key-ring ciphertext of the audio, at most 2 MB plaintext
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
@@ -415,6 +415,8 @@ export const PERMISSION_CATALOG = [
|
|||||||
// Beute in 3D: hive systems and their part-type catalog. Reading the
|
// Beute in 3D: hive systems and their part-type catalog. Reading the
|
||||||
// catalog and writing a colony's setup use the colonies permissions.
|
// catalog and writing a colony's setup use the colonies permissions.
|
||||||
...group('Beutensysteme', 'hive-systems', [['manage', 'Pflegen']], 3900),
|
...group('Beutensysteme', 'hive-systems', [['manage', 'Pflegen']], 3900),
|
||||||
|
// T7 Freihand: voice notes. Every endpoint also needs inspections:create.
|
||||||
|
...group('Sprache', 'voice-captures', [['use', 'Benutzen']], 4000),
|
||||||
] as const satisfies readonly PermissionCatalogEntry[];
|
] as const satisfies readonly PermissionCatalogEntry[];
|
||||||
|
|
||||||
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
|
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
|
||||||
@@ -1536,6 +1536,20 @@ describe('BackupService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('excludes VoiceCapture and VoiceCaptureAudio with a reason', async () => {
|
||||||
|
const bundle = await service.exportAll();
|
||||||
|
|
||||||
|
for (const [model, key] of [
|
||||||
|
['VoiceCapture', 'voiceCapture'],
|
||||||
|
['VoiceCaptureAudio', 'voiceCaptureAudio'],
|
||||||
|
] as const) {
|
||||||
|
expect(bundle.data).not.toHaveProperty(key);
|
||||||
|
expect(MODELS_EXCLUDED_FROM_BACKUP[model]).toMatch(
|
||||||
|
/ephemeral, personal data, not deliverable after a restore/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('restores phenology reports after the apiaries, remapping a user that does not exist', async () => {
|
it('restores phenology reports after the apiaries, remapping a user that does not exist', async () => {
|
||||||
await importData({
|
await importData({
|
||||||
apiary: [{ id: 'apiary-1' }],
|
apiary: [{ id: 'apiary-1' }],
|
||||||
|
|||||||
@@ -158,6 +158,10 @@ export const MODELS_EXCLUDED_FROM_BACKUP: Readonly<
|
|||||||
NotificationDelivery:
|
NotificationDelivery:
|
||||||
'Delivery queue - restoring it would resend old messages.',
|
'Delivery queue - restoring it would resend old messages.',
|
||||||
TelegramPollState: 'Update offset and poll lease of this deployment.',
|
TelegramPollState: 'Update offset and poll lease of this deployment.',
|
||||||
|
VoiceCapture:
|
||||||
|
'Voice notes are ephemeral, personal data, not deliverable after a restore (content is deleted after at most 7 days).',
|
||||||
|
VoiceCaptureAudio:
|
||||||
|
'Encrypted audio of voice notes: ephemeral, personal data, not deliverable after a restore.',
|
||||||
};
|
};
|
||||||
|
|
||||||
const DOCUMENT_ENTITY_TYPES = new Set<string>(
|
const DOCUMENT_ENTITY_TYPES = new Set<string>(
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ describe('lock keys per table', () => {
|
|||||||
'AuthorityNotice',
|
'AuthorityNotice',
|
||||||
'ApiaryRestriction',
|
'ApiaryRestriction',
|
||||||
'ComplianceAcknowledgement',
|
'ComplianceAcknowledgement',
|
||||||
|
'VoiceCapture',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const LOCKABLE_TABLES = {
|
|||||||
AuthorityNotice: lockable('AuthorityNotice'),
|
AuthorityNotice: lockable('AuthorityNotice'),
|
||||||
ApiaryRestriction: lockable('ApiaryRestriction'),
|
ApiaryRestriction: lockable('ApiaryRestriction'),
|
||||||
ComplianceAcknowledgement: lockable('ComplianceAcknowledgement'),
|
ComplianceAcknowledgement: lockable('ComplianceAcknowledgement'),
|
||||||
|
VoiceCapture: lockable('VoiceCapture'),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type LockableTable = keyof typeof LOCKABLE_TABLES;
|
export type LockableTable = keyof typeof LOCKABLE_TABLES;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
@@ -95,3 +97,33 @@ describe('Search and filters (e2e)', () => {
|
|||||||
expect(colonies.map((entry) => entry.id)).toEqual([dissolvedColonyId]);
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user