POST /voice-captures und /voice-captures/text antworten mit 403 VOICE_RECORDING_DISABLED („Sprachnotizen sind gerade ausgeschaltet.“), solange VOICE_RECORDING_ENABLED nicht true ist; Liste, Abruf, Wiederholen und Verwerfen bleiben möglich (Ruling 14). Refs #27 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
938 lines
32 KiB
TypeScript
938 lines
32 KiB
TypeScript
import { INestApplication } from '@nestjs/common';
|
|
import * as bcrypt from 'bcryptjs';
|
|
import request from 'supertest';
|
|
import { App } from 'supertest/types';
|
|
import { Prisma } from '../generated/prisma';
|
|
import { berlinToday } from '../src/common/dates';
|
|
import { PrismaService } from '../src/prisma/prisma.service';
|
|
import { sealVoiceText, voiceAad } from '../src/voice/core/voice-crypto';
|
|
import { sealTranscript } from '../src/voice/core/transcript';
|
|
import { login } from './utils/api';
|
|
import { createTestApp } from './utils/create-test-app';
|
|
|
|
const WEBM = Buffer.concat([
|
|
Buffer.from([0x1a, 0x45, 0xdf, 0xa3]),
|
|
Buffer.from('opus-bytes-of-a-spoken-note'),
|
|
]);
|
|
const PNG = Buffer.concat([
|
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
Buffer.from('not audio'),
|
|
]);
|
|
const SPOKEN =
|
|
'Koenigin gesehen, Stifte da, vier Brutwaben, ruhig, Futter niedrig, Honigraum kommt naechste Woche drauf';
|
|
|
|
interface CaptureBody {
|
|
id: string;
|
|
status: string;
|
|
colonyId?: string;
|
|
colonyName?: string;
|
|
lastErrorCode?: string | null;
|
|
transcript?: string | null;
|
|
suggestions?: { field: string; value: unknown; quote: string }[] | null;
|
|
unclear?: string[];
|
|
dropped?: number;
|
|
code?: string;
|
|
message?: string;
|
|
}
|
|
|
|
/**
|
|
* Runs in parallel with the other e2e suites on one database: everything
|
|
* here belongs to this file's own users (found by the suffix in the e-mail),
|
|
* every query is scoped to them, and afterAll deletes by suffix.
|
|
*/
|
|
describe('Voice captures (e2e)', () => {
|
|
let app: INestApplication<App>;
|
|
let prisma: PrismaService;
|
|
const suffix = `${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
const password = 'SuperSecret123!';
|
|
const emailOf = (name: string) => `voice-e2e-${name}-${suffix}@example.com`;
|
|
const roleKey = `VOICE_E2E_ONLY_VOICE_${suffix}`;
|
|
const inspectionsRoleKey = `VOICE_E2E_ONLY_INSPECTIONS_${suffix}`;
|
|
const tokens: Record<string, string> = {};
|
|
const userIds: Record<string, string> = {};
|
|
let apiaryId: string;
|
|
let colonyId: string;
|
|
let deadColonyId: string;
|
|
let keyCounter = 0;
|
|
|
|
const server = () => app.getHttpServer();
|
|
const as = (name: string) => ({ Authorization: `Bearer ${tokens[name]}` });
|
|
const nextKey = () =>
|
|
`voice-e2e-${suffix}-${(keyCounter += 1).toString().padStart(4, '0')}`;
|
|
const today = () => berlinToday();
|
|
let addressCounter = 0;
|
|
const nextAddress = () => {
|
|
addressCounter += 1;
|
|
return `10.${Math.floor(addressCounter / 250)}.${addressCounter % 250}.7`;
|
|
};
|
|
|
|
function upload(
|
|
who: string,
|
|
options: {
|
|
bytes?: Buffer;
|
|
type?: string;
|
|
colony?: string;
|
|
date?: string;
|
|
duration?: string;
|
|
key?: string;
|
|
} = {},
|
|
) {
|
|
return (
|
|
request(server())
|
|
.post('/api/v1/voice-captures')
|
|
.set(as(who))
|
|
// Every upload comes from its own address: the IP throttle (10 a minute)
|
|
// is not what these tests are about.
|
|
.set('X-Forwarded-For', nextAddress())
|
|
.set('Idempotency-Key', options.key ?? nextKey())
|
|
.field('colonyId', options.colony ?? colonyId)
|
|
.field('inspectionDate', options.date ?? today())
|
|
.field('durationSec', options.duration ?? '30')
|
|
.attach('audio', options.bytes ?? WEBM, {
|
|
filename: 'note.webm',
|
|
contentType: options.type ?? 'audio/webm',
|
|
})
|
|
);
|
|
}
|
|
|
|
function sendText(who: string, text = SPOKEN, extra: object = {}) {
|
|
return request(server())
|
|
.post('/api/v1/voice-captures/text')
|
|
.set(as(who))
|
|
.set('Idempotency-Key', nextKey())
|
|
.send({ colonyId, inspectionDate: today(), text, ...extra });
|
|
}
|
|
|
|
async function createUser(
|
|
name: string,
|
|
role: { key?: string; id?: string },
|
|
): Promise<void> {
|
|
const passwordHash = await bcrypt.hash(password, 12);
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
email: emailOf(name),
|
|
passwordHash,
|
|
name: `Voice E2E ${name}`,
|
|
role: { connect: role.key ? { key: role.key } : { id: role.id } },
|
|
},
|
|
});
|
|
userIds[name] = user.id;
|
|
tokens[name] = (await login(app, emailOf(name), password)).accessToken;
|
|
}
|
|
|
|
const originalRecording = process.env.VOICE_RECORDING_ENABLED;
|
|
|
|
beforeAll(async () => {
|
|
// Notes are only accepted while recording is on; this file turns it on.
|
|
process.env.VOICE_RECORDING_ENABLED = 'true';
|
|
app = await createTestApp();
|
|
prisma = app.get(PrismaService);
|
|
|
|
const onlyVoice = await prisma.role.create({
|
|
data: { key: roleKey, name: 'Nur Sprache' },
|
|
});
|
|
const permission = await prisma.permission.findUniqueOrThrow({
|
|
where: { key: 'voice-captures:use' },
|
|
});
|
|
await prisma.rolePermission.create({
|
|
data: { roleId: onlyVoice.id, permissionId: permission.id },
|
|
});
|
|
|
|
const onlyInspections = await prisma.role.create({
|
|
data: { key: inspectionsRoleKey, name: 'Nur Durchsicht' },
|
|
});
|
|
const inspectionsPermission = await prisma.permission.findUniqueOrThrow({
|
|
where: { key: 'inspections:create' },
|
|
});
|
|
await prisma.rolePermission.create({
|
|
data: {
|
|
roleId: onlyInspections.id,
|
|
permissionId: inspectionsPermission.id,
|
|
},
|
|
});
|
|
|
|
await createUser('alice', { key: 'BEEKEEPER' });
|
|
await createUser('bob', { key: 'BEEKEEPER' });
|
|
await createUser('limits', { key: 'BEEKEEPER' });
|
|
await createUser('admin', { key: 'ADMIN' });
|
|
await createUser('readonly', { key: 'READONLY' });
|
|
await createUser('onlyvoice', { id: onlyVoice.id });
|
|
await createUser('onlyinspections', { id: onlyInspections.id });
|
|
|
|
const apiary = await prisma.apiary.create({
|
|
data: { name: `Voice E2E Stand ${suffix}` },
|
|
});
|
|
apiaryId = apiary.id;
|
|
colonyId = (
|
|
await prisma.colony.create({
|
|
data: {
|
|
code: `VOICE-${suffix}`,
|
|
apiaryId,
|
|
colonyType: 'PRODUCTION_COLONY',
|
|
},
|
|
})
|
|
).id;
|
|
deadColonyId = (
|
|
await prisma.colony.create({
|
|
data: {
|
|
code: `VOICE-DEAD-${suffix}`,
|
|
apiaryId,
|
|
colonyType: 'PRODUCTION_COLONY',
|
|
status: 'DISSOLVED',
|
|
},
|
|
})
|
|
).id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
// By suffix, so a failed setup leaks nothing.
|
|
const users = await prisma.user.findMany({
|
|
where: { email: { contains: suffix } },
|
|
select: { id: true },
|
|
});
|
|
const ids = users.map((user) => user.id);
|
|
await prisma.voiceCapture.deleteMany({
|
|
where: { ownerUserId: { in: ids } },
|
|
});
|
|
await prisma.auditLog.deleteMany({
|
|
where: { userId: { in: ids }, entityType: 'VoiceCapture' },
|
|
});
|
|
await prisma.idempotencyRecord.deleteMany({
|
|
where: { userId: { in: ids } },
|
|
});
|
|
await prisma.refreshToken.deleteMany({ where: { userId: { in: ids } } });
|
|
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
|
await prisma.role.deleteMany({
|
|
where: { key: { in: [roleKey, inspectionsRoleKey] } },
|
|
});
|
|
await prisma.colony.deleteMany({
|
|
where: { code: { in: [`VOICE-${suffix}`, `VOICE-DEAD-${suffix}`] } },
|
|
});
|
|
await prisma.apiary.deleteMany({
|
|
where: { name: `Voice E2E Stand ${suffix}` },
|
|
});
|
|
await app.close();
|
|
if (originalRecording === undefined) {
|
|
delete process.env.VOICE_RECORDING_ENABLED;
|
|
} else {
|
|
process.env.VOICE_RECORDING_ENABLED = originalRecording;
|
|
}
|
|
});
|
|
|
|
const ownRows = (name: string) =>
|
|
prisma.voiceCapture.findMany({ where: { ownerUserId: userIds[name] } });
|
|
|
|
beforeEach(async () => {
|
|
// Only the rows of this file's own users; other suites are untouched.
|
|
await prisma.voiceCapture.deleteMany({
|
|
where: { ownerUserId: { in: Object.values(userIds) } },
|
|
});
|
|
});
|
|
|
|
describe('rights', () => {
|
|
it('needs voice-captures:use AND inspections:create on every user endpoint', async () => {
|
|
const calls: [string, string][] = [
|
|
['get', '/api/v1/voice-captures'],
|
|
['get', '/api/v1/voice-captures/config'],
|
|
['get', '/api/v1/voice-captures/some-id'],
|
|
['post', '/api/v1/voice-captures/some-id/retry'],
|
|
['post', '/api/v1/voice-captures/some-id/discard'],
|
|
['post', '/api/v1/voice-captures/discard-all'],
|
|
];
|
|
for (const who of ['readonly', 'onlyvoice', 'onlyinspections']) {
|
|
for (const [method, url] of calls) {
|
|
const response = await request(server())
|
|
[method as 'get' | 'post'](url)
|
|
.set(as(who));
|
|
expect({ who, url, status: response.status }).toEqual({
|
|
who,
|
|
url,
|
|
status: 403,
|
|
});
|
|
}
|
|
expect((await upload(who)).status).toBe(403);
|
|
expect((await sendText(who)).status).toBe(403);
|
|
}
|
|
});
|
|
|
|
it('lets only system:manage read the counters', async () => {
|
|
await request(server())
|
|
.get('/api/v1/voice-captures/stats')
|
|
.set(as('alice'))
|
|
.expect(403);
|
|
const response = await request(server())
|
|
.get('/api/v1/voice-captures/stats')
|
|
.set(as('admin'))
|
|
.expect(200);
|
|
expect(response.body).toEqual({
|
|
byStatus: expect.any(Object) as unknown,
|
|
byError: expect.any(Object) as unknown,
|
|
tokens: {
|
|
input: expect.any(Number) as unknown,
|
|
output: expect.any(Number) as unknown,
|
|
},
|
|
byModel: expect.any(Object) as unknown,
|
|
});
|
|
});
|
|
|
|
it('answers no-store', async () => {
|
|
const response = await request(server())
|
|
.get('/api/v1/voice-captures')
|
|
.set(as('alice'))
|
|
.expect(200);
|
|
expect(response.headers['cache-control']).toContain('no-store');
|
|
});
|
|
});
|
|
|
|
it('runs the database session in UTC, which the plain now() writes rely on', async () => {
|
|
const rows = await prisma.$queryRaw<{ TimeZone: string }[]>`SHOW TimeZone`;
|
|
expect(rows[0].TimeZone).toBe('UTC');
|
|
});
|
|
|
|
describe('recording switch', () => {
|
|
it('refuses new notes while off but still lists, reads and discards', async () => {
|
|
const id = ((await sendText('alice').expect(201)).body as CaptureBody).id;
|
|
process.env.VOICE_RECORDING_ENABLED = 'false';
|
|
try {
|
|
const up = await upload('alice').expect(403);
|
|
expect(up.body).toMatchObject({
|
|
code: 'VOICE_RECORDING_DISABLED',
|
|
message: 'Sprachnotizen sind gerade ausgeschaltet.',
|
|
});
|
|
await sendText('alice').expect(403);
|
|
await request(server())
|
|
.get('/api/v1/voice-captures')
|
|
.set(as('alice'))
|
|
.expect(200);
|
|
await request(server())
|
|
.get(`/api/v1/voice-captures/${id}`)
|
|
.set(as('alice'))
|
|
.expect(200);
|
|
await request(server())
|
|
.post(`/api/v1/voice-captures/${id}/discard`)
|
|
.set(as('alice'))
|
|
.expect(204);
|
|
} finally {
|
|
process.env.VOICE_RECORDING_ENABLED = 'true';
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('upload', () => {
|
|
it('stores the audio sealed and answers QUEUED', async () => {
|
|
const response = await upload('alice').expect(201);
|
|
expect(response.body).toEqual({
|
|
id: expect.any(String) as unknown,
|
|
status: 'QUEUED',
|
|
});
|
|
const id = (response.body as CaptureBody).id;
|
|
const row = await prisma.voiceCapture.findUniqueOrThrow({
|
|
where: { id },
|
|
include: { audio: true },
|
|
});
|
|
expect(row).toMatchObject({
|
|
ownerUserId: userIds.alice,
|
|
colonyId,
|
|
source: 'AUDIO',
|
|
status: 'QUEUED',
|
|
audioMime: 'audio/webm',
|
|
audioBytes: WEBM.length,
|
|
durationSec: 30,
|
|
attempts: 0,
|
|
transcriptEnc: null,
|
|
});
|
|
expect(row.contentExpiresAt.getTime()).toBeGreaterThan(
|
|
row.createdAt.getTime() + 6.9 * 86_400_000,
|
|
);
|
|
expect(row.audio).not.toBeNull();
|
|
expect(Buffer.from(row.audio!.dataEnc).includes(WEBM)).toBe(false);
|
|
});
|
|
|
|
it('audits the creation without content', async () => {
|
|
const id = ((await upload('alice').expect(201)).body as CaptureBody).id;
|
|
const entries = await prisma.auditLog.findMany({
|
|
where: { entityType: 'VoiceCapture', entityId: id },
|
|
});
|
|
expect(entries).toHaveLength(1);
|
|
expect(entries[0]).toMatchObject({
|
|
action: 'CREATE',
|
|
userId: userIds.alice,
|
|
});
|
|
expect(JSON.stringify(entries[0].afterData)).not.toContain('opus');
|
|
});
|
|
|
|
it('is idempotent for a repeated key', async () => {
|
|
const key = nextKey();
|
|
const first = await upload('alice', { key }).expect(201);
|
|
const second = await upload('alice', { key }).expect(201);
|
|
expect((second.body as CaptureBody).id).toBe(
|
|
(first.body as CaptureBody).id,
|
|
);
|
|
expect(await ownRows('alice')).toHaveLength(1);
|
|
});
|
|
|
|
it('takes WebM bytes with a wrong Content-Type and refuses PNG bytes as audio', async () => {
|
|
const ok = await upload('alice', { type: 'image/png' }).expect(201);
|
|
const id = (ok.body as CaptureBody).id;
|
|
const row = await prisma.voiceCapture.findUniqueOrThrow({
|
|
where: { id },
|
|
});
|
|
expect(row.audioMime).toBe('audio/webm');
|
|
|
|
const bad = await upload('alice', {
|
|
bytes: PNG,
|
|
type: 'audio/webm',
|
|
}).expect(415);
|
|
expect((bad.body as CaptureBody).code).toBe('UNSUPPORTED_AUDIO');
|
|
});
|
|
|
|
it('refuses a file above 2 MB with 413 TOO_LARGE', async () => {
|
|
const big = Buffer.concat([WEBM, Buffer.alloc(2_097_152)]);
|
|
const response = await upload('alice', { bytes: big }).expect(413);
|
|
expect((response.body as CaptureBody).code).toBe('TOO_LARGE');
|
|
expect(await ownRows('alice')).toHaveLength(0);
|
|
});
|
|
|
|
it('refuses a request without audio, a dead colony and bad dates', async () => {
|
|
await request(server())
|
|
.post('/api/v1/voice-captures')
|
|
.set(as('alice'))
|
|
.field('colonyId', colonyId)
|
|
.field('inspectionDate', today())
|
|
.field('durationSec', '30')
|
|
.expect(400);
|
|
await upload('alice', { colony: deadColonyId }).expect(400);
|
|
await upload('alice', { colony: 'does-not-exist' }).expect(400);
|
|
await upload('alice', { date: '2099-01-01' }).expect(400);
|
|
await upload('alice', { date: '2020-01-01' }).expect(400);
|
|
await upload('alice', { duration: '999' }).expect(400);
|
|
expect(await ownRows('alice')).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('text', () => {
|
|
it('stores the sealed text as TRANSCRIBED and gives it back to the owner only', async () => {
|
|
const created = await sendText('alice').expect(201);
|
|
const id = (created.body as CaptureBody).id;
|
|
expect(created.body).toEqual({ id, status: 'TRANSCRIBED' });
|
|
const row = await prisma.voiceCapture.findUniqueOrThrow({
|
|
where: { id },
|
|
});
|
|
expect(row.source).toBe('TEXT');
|
|
expect(row.transcriptEnc).not.toBeNull();
|
|
expect(row.transcriptEnc).not.toContain('Koenigin');
|
|
|
|
const detail = await request(server())
|
|
.get(`/api/v1/voice-captures/${id}`)
|
|
.set(as('alice'))
|
|
.expect(200);
|
|
expect(detail.body).toMatchObject({
|
|
id,
|
|
status: 'TRANSCRIBED',
|
|
transcript: SPOKEN,
|
|
suggestions: null,
|
|
colonyName: `VOICE-${suffix}`,
|
|
inspectionDate: today(),
|
|
});
|
|
});
|
|
|
|
it('refuses a text outside 20 to 4000 characters', async () => {
|
|
await sendText('alice', 'zu kurz').expect(400);
|
|
await sendText('alice', 'a'.repeat(4001)).expect(400);
|
|
await sendText('alice', 'a'.repeat(4000)).expect(201);
|
|
});
|
|
});
|
|
|
|
describe('limits', () => {
|
|
it('allows ten open notes and answers the eleventh with 429 TOO_MANY_OPEN', async () => {
|
|
for (let index = 0; index < 10; index += 1) {
|
|
await sendText('limits').expect(201);
|
|
}
|
|
const response = await sendText('limits').expect(429);
|
|
expect(response.body).toMatchObject({
|
|
code: 'TOO_MANY_OPEN',
|
|
message:
|
|
'Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.',
|
|
});
|
|
expect(await ownRows('limits')).toHaveLength(10);
|
|
});
|
|
|
|
it('counts per user: another person is not blocked', async () => {
|
|
for (let index = 0; index < 10; index += 1) {
|
|
await sendText('limits').expect(201);
|
|
}
|
|
await sendText('alice').expect(201);
|
|
});
|
|
|
|
it('does not count finished notes as open, but counts them per day and minute', async () => {
|
|
for (let index = 0; index < 10; index += 1) {
|
|
const id = ((await sendText('limits').expect(201)).body as CaptureBody)
|
|
.id;
|
|
await request(server())
|
|
.post(`/api/v1/voice-captures/${id}/discard`)
|
|
.set(as('limits'))
|
|
.expect(204);
|
|
}
|
|
// No note is open any more, but ten were created within the minute.
|
|
await sendText('limits').expect(429);
|
|
});
|
|
|
|
it('limits new notes to thirty per UTC day', async () => {
|
|
const startOfUtcDay = new Date().setUTCHours(0, 0, 0, 0);
|
|
const createdAt = new Date(Math.max(Date.now() - 90_000, startOfUtcDay));
|
|
await prisma.voiceCapture.createMany({
|
|
data: Array.from({ length: 30 }, () => ({
|
|
ownerUserId: userIds.limits,
|
|
colonyId,
|
|
inspectionDate: new Date(`${today()}T00:00:00.000Z`),
|
|
source: 'TEXT' as const,
|
|
status: 'DISCARDED' as const,
|
|
contentExpiresAt: new Date(Date.now() + 86_400_000),
|
|
createdAt,
|
|
})),
|
|
});
|
|
const response = await sendText('limits').expect(429);
|
|
expect((response.body as CaptureBody).code).toBe('TOO_MANY_OPEN');
|
|
});
|
|
});
|
|
|
|
describe('list and get', () => {
|
|
it('lists only the own notes, newest first, without content', async () => {
|
|
const first = ((await sendText('alice').expect(201)).body as CaptureBody)
|
|
.id;
|
|
const second = ((await upload('alice').expect(201)).body as CaptureBody)
|
|
.id;
|
|
await sendText('bob').expect(201);
|
|
|
|
const response = await request(server())
|
|
.get('/api/v1/voice-captures')
|
|
.set(as('alice'))
|
|
.expect(200);
|
|
const items = response.body as CaptureBody[];
|
|
expect(items.map((item) => item.id)).toEqual([second, first]);
|
|
for (const item of items) {
|
|
expect(item).not.toHaveProperty('transcript');
|
|
expect(item).not.toHaveProperty('suggestions');
|
|
expect(item.colonyName).toBe(`VOICE-${suffix}`);
|
|
}
|
|
expect(JSON.stringify(items)).not.toContain('Koenigin');
|
|
});
|
|
|
|
it('filters by status and refuses an unknown status', async () => {
|
|
await sendText('alice').expect(201);
|
|
await upload('alice').expect(201);
|
|
const queued = await request(server())
|
|
.get('/api/v1/voice-captures?status=QUEUED')
|
|
.set(as('alice'))
|
|
.expect(200);
|
|
expect((queued.body as CaptureBody[]).map((item) => item.status)).toEqual(
|
|
['QUEUED'],
|
|
);
|
|
await request(server())
|
|
.get('/api/v1/voice-captures?status=NOPE')
|
|
.set(as('alice'))
|
|
.expect(400);
|
|
});
|
|
|
|
it('serves config before the :id route', async () => {
|
|
const response = await request(server())
|
|
.get('/api/v1/voice-captures/config')
|
|
.set(as('alice'))
|
|
.expect(200);
|
|
expect(response.body).toEqual({
|
|
recordingEnabled: expect.any(Boolean) as unknown,
|
|
extractionEnabled: expect.any(Boolean) as unknown,
|
|
model: expect.stringMatching(/^claude-/) as unknown,
|
|
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
|
|
});
|
|
});
|
|
|
|
it('reports extraction as off while a note is parked with EXTRACT_DISABLED', async () => {
|
|
await sendText('alice').expect(201);
|
|
await prisma.voiceCapture.updateMany({
|
|
where: { ownerUserId: userIds.alice },
|
|
data: { lastErrorCode: 'EXTRACT_DISABLED' },
|
|
});
|
|
const response = await request(server())
|
|
.get('/api/v1/voice-captures/config')
|
|
.set(as('alice'))
|
|
.expect(200);
|
|
expect(
|
|
(response.body as { extractionEnabled: boolean }).extractionEnabled,
|
|
).toBe(false);
|
|
});
|
|
|
|
it('shows suggestions of a READY note and hides content while it is being worked on', async () => {
|
|
const id = ((await sendText('alice').expect(201)).body as CaptureBody).id;
|
|
const payload = {
|
|
suggestions: [
|
|
{
|
|
field: 'queenSeen',
|
|
value: true,
|
|
quote: 'Koenigin gesehen',
|
|
corrected: false,
|
|
truncated: false,
|
|
},
|
|
{
|
|
field: 'broodFrames',
|
|
value: 4,
|
|
quote: 'vier Brutwaben',
|
|
corrected: false,
|
|
truncated: false,
|
|
},
|
|
],
|
|
unclear: ['Etwas zum Futter'],
|
|
dropped: 1,
|
|
truncated: 0,
|
|
};
|
|
await prisma.voiceCapture.update({
|
|
where: { id },
|
|
data: {
|
|
status: 'READY',
|
|
suggestionEnc: sealVoiceText(
|
|
JSON.stringify(payload),
|
|
voiceAad(id, 'suggestion'),
|
|
),
|
|
},
|
|
});
|
|
const ready = await request(server())
|
|
.get(`/api/v1/voice-captures/${id}`)
|
|
.set(as('alice'))
|
|
.expect(200);
|
|
expect(ready.body).toMatchObject({
|
|
status: 'READY',
|
|
transcript: SPOKEN,
|
|
unclear: ['Etwas zum Futter'],
|
|
dropped: 1,
|
|
});
|
|
expect((ready.body as CaptureBody).suggestions).toEqual([
|
|
{
|
|
field: 'queenSeen',
|
|
value: true,
|
|
quote: 'Koenigin gesehen',
|
|
corrected: false,
|
|
truncated: false,
|
|
},
|
|
{
|
|
field: 'broodFrames',
|
|
value: 4,
|
|
quote: 'vier Brutwaben',
|
|
corrected: false,
|
|
truncated: false,
|
|
},
|
|
]);
|
|
|
|
await prisma.voiceCapture.update({
|
|
where: { id },
|
|
data: { status: 'EXTRACTING' },
|
|
});
|
|
const working = await request(server())
|
|
.get(`/api/v1/voice-captures/${id}`)
|
|
.set(as('alice'))
|
|
.expect(200);
|
|
expect(working.body).toMatchObject({
|
|
transcript: null,
|
|
suggestions: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('foreign ids', () => {
|
|
it('answers 404 to another person on get, retry and discard, and changes nothing', async () => {
|
|
const id = ((await sendText('alice').expect(201)).body as CaptureBody).id;
|
|
await request(server())
|
|
.get(`/api/v1/voice-captures/${id}`)
|
|
.set(as('bob'))
|
|
.expect(404);
|
|
await request(server())
|
|
.post(`/api/v1/voice-captures/${id}/retry`)
|
|
.set('X-Forwarded-For', nextAddress())
|
|
.set(as('bob'))
|
|
.expect(404);
|
|
await request(server())
|
|
.post(`/api/v1/voice-captures/${id}/discard`)
|
|
.set(as('bob'))
|
|
.expect(404);
|
|
const row = await prisma.voiceCapture.findUniqueOrThrow({
|
|
where: { id },
|
|
});
|
|
expect(row.status).toBe('TRANSCRIBED');
|
|
expect(row.transcriptEnc).not.toBeNull();
|
|
await request(server())
|
|
.get('/api/v1/voice-captures/never-existed')
|
|
.set(as('alice'))
|
|
.expect(404);
|
|
});
|
|
});
|
|
|
|
describe('retry', () => {
|
|
async function failed(
|
|
who: string,
|
|
data: Prisma.VoiceCaptureUncheckedUpdateInput,
|
|
options: { audio?: boolean; text?: boolean } = {},
|
|
): Promise<string> {
|
|
const { audio = true, text = false } = options;
|
|
const id = ((await upload(who).expect(201)).body as CaptureBody).id;
|
|
if (!audio) {
|
|
await prisma.voiceCaptureAudio.deleteMany({ where: { captureId: id } });
|
|
}
|
|
await prisma.voiceCapture.update({
|
|
where: { id },
|
|
data: {
|
|
status: 'FAILED',
|
|
attempts: 3,
|
|
...(text
|
|
? { transcriptEnc: sealTranscript(id, { v: 1, text: SPOKEN }) }
|
|
: {}),
|
|
...data,
|
|
},
|
|
});
|
|
return id;
|
|
}
|
|
|
|
it('puts a failed transcription back to QUEUED with attempts 0', async () => {
|
|
const id = await failed('alice', { lastErrorCode: 'TRANSCRIBE_FAILED' });
|
|
const response = await request(server())
|
|
.post(`/api/v1/voice-captures/${id}/retry`)
|
|
.set('X-Forwarded-For', nextAddress())
|
|
.set(as('alice'))
|
|
.expect(200);
|
|
expect(response.body).toMatchObject({
|
|
id,
|
|
status: 'QUEUED',
|
|
lastErrorCode: null,
|
|
});
|
|
const row = await prisma.voiceCapture.findUniqueOrThrow({
|
|
where: { id },
|
|
});
|
|
expect(row).toMatchObject({
|
|
status: 'QUEUED',
|
|
attempts: 0,
|
|
lastErrorCode: null,
|
|
claimToken: null,
|
|
});
|
|
});
|
|
|
|
it('puts a failed extraction with text back to TRANSCRIBED with attempts 0', async () => {
|
|
const id = await failed(
|
|
'alice',
|
|
{ lastErrorCode: 'EXTRACT_FAILED' },
|
|
{ audio: false, text: true },
|
|
);
|
|
const response = await request(server())
|
|
.post(`/api/v1/voice-captures/${id}/retry`)
|
|
.set('X-Forwarded-For', nextAddress())
|
|
.set(as('alice'))
|
|
.expect(200);
|
|
expect((response.body as CaptureBody).status).toBe('TRANSCRIBED');
|
|
const row = await prisma.voiceCapture.findUniqueOrThrow({
|
|
where: { id },
|
|
});
|
|
expect(row.attempts).toBe(0);
|
|
});
|
|
|
|
it('counts a retry against the open limit', async () => {
|
|
const id = await failed(
|
|
'limits',
|
|
{ lastErrorCode: 'EXTRACT_FAILED' },
|
|
{ audio: false, text: true },
|
|
);
|
|
// Rows made directly: ten API calls would also hit the per-minute limit.
|
|
await prisma.voiceCapture.createMany({
|
|
data: Array.from({ length: 10 }, () => ({
|
|
ownerUserId: userIds.limits,
|
|
colonyId,
|
|
inspectionDate: new Date(`${today()}T00:00:00.000Z`),
|
|
source: 'TEXT' as const,
|
|
status: 'TRANSCRIBED' as const,
|
|
contentExpiresAt: new Date(Date.now() + 86_400_000),
|
|
})),
|
|
});
|
|
const response = await request(server())
|
|
.post(`/api/v1/voice-captures/${id}/retry`)
|
|
.set('X-Forwarded-For', nextAddress())
|
|
.set(as('limits'))
|
|
.expect(429);
|
|
expect((response.body as CaptureBody).code).toBe('TOO_MANY_OPEN');
|
|
const row = await prisma.voiceCapture.findUniqueOrThrow({
|
|
where: { id },
|
|
});
|
|
expect(row.status).toBe('FAILED');
|
|
});
|
|
|
|
it('refuses to retry a transcription whose audio is older than 24 hours', async () => {
|
|
const id = await failed('alice', { lastErrorCode: 'TRANSCRIBE_FAILED' });
|
|
await prisma.voiceCaptureAudio.update({
|
|
where: { captureId: id },
|
|
data: { createdAt: new Date(Date.now() - 25 * 3_600_000) },
|
|
});
|
|
const response = await request(server())
|
|
.post(`/api/v1/voice-captures/${id}/retry`)
|
|
.set('X-Forwarded-For', nextAddress())
|
|
.set(as('alice'))
|
|
.expect(409);
|
|
expect((response.body as CaptureBody).code).toBe('VOICE_CAPTURE_GONE');
|
|
});
|
|
|
|
it('refuses a failed note without content, an expired one and one that is not failed', async () => {
|
|
const noContent = await failed(
|
|
'alice',
|
|
{ lastErrorCode: 'NO_SPEECH' },
|
|
{ audio: false },
|
|
);
|
|
const gone = await request(server())
|
|
.post(`/api/v1/voice-captures/${noContent}/retry`)
|
|
.set('X-Forwarded-For', nextAddress())
|
|
.set(as('alice'))
|
|
.expect(409);
|
|
expect((gone.body as CaptureBody).code).toBe('VOICE_CAPTURE_GONE');
|
|
|
|
const expired = await failed('alice', {
|
|
lastErrorCode: 'TRANSCRIBE_FAILED',
|
|
contentExpiresAt: new Date(Date.now() - 60_000),
|
|
});
|
|
const late = await request(server())
|
|
.post(`/api/v1/voice-captures/${expired}/retry`)
|
|
.set('X-Forwarded-For', nextAddress())
|
|
.set(as('alice'))
|
|
.expect(409);
|
|
expect((late.body as CaptureBody).code).toBe('VOICE_CAPTURE_GONE');
|
|
|
|
const queued = ((await upload('alice').expect(201)).body as CaptureBody)
|
|
.id;
|
|
await request(server())
|
|
.post(`/api/v1/voice-captures/${queued}/retry`)
|
|
.set('X-Forwarded-For', nextAddress())
|
|
.set(as('alice'))
|
|
.expect(409);
|
|
});
|
|
});
|
|
|
|
describe('discard', () => {
|
|
it('deletes audio, transcript and suggestion at once and is idempotent', async () => {
|
|
const audioId = ((await upload('alice').expect(201)).body as CaptureBody)
|
|
.id;
|
|
const textId = ((await sendText('alice').expect(201)).body as CaptureBody)
|
|
.id;
|
|
await prisma.voiceCapture.update({
|
|
where: { id: textId },
|
|
data: {
|
|
status: 'READY',
|
|
suggestionEnc: sealVoiceText('{"suggestions":[]}', 'x'),
|
|
},
|
|
});
|
|
for (const id of [audioId, textId]) {
|
|
await request(server())
|
|
.post(`/api/v1/voice-captures/${id}/discard`)
|
|
.set(as('alice'))
|
|
.expect(204);
|
|
await request(server())
|
|
.post(`/api/v1/voice-captures/${id}/discard`)
|
|
.set(as('alice'))
|
|
.expect(204);
|
|
}
|
|
const rows = await prisma.voiceCapture.findMany({
|
|
where: { id: { in: [audioId, textId] } },
|
|
include: { audio: true },
|
|
});
|
|
expect(rows).toHaveLength(2);
|
|
for (const row of rows) {
|
|
expect(row).toMatchObject({
|
|
status: 'DISCARDED',
|
|
transcriptEnc: null,
|
|
suggestionEnc: null,
|
|
audio: null,
|
|
claimToken: null,
|
|
});
|
|
expect(row.contentDeletedAt).not.toBeNull();
|
|
}
|
|
});
|
|
|
|
it('also stops a note a worker has claimed', async () => {
|
|
const id = ((await upload('alice').expect(201)).body as CaptureBody).id;
|
|
await prisma.voiceCapture.update({
|
|
where: { id },
|
|
data: {
|
|
status: 'TRANSCRIBING',
|
|
claimToken: 'token-of-a-running-job',
|
|
claimedAt: new Date(),
|
|
},
|
|
});
|
|
await request(server())
|
|
.post(`/api/v1/voice-captures/${id}/discard`)
|
|
.set(as('alice'))
|
|
.expect(204);
|
|
const row = await prisma.voiceCapture.findUniqueOrThrow({
|
|
where: { id },
|
|
});
|
|
expect(row).toMatchObject({
|
|
status: 'DISCARDED',
|
|
claimToken: null,
|
|
claimedAt: null,
|
|
});
|
|
});
|
|
|
|
it('leaves a confirmed note as it is', async () => {
|
|
const id = ((await sendText('alice').expect(201)).body as CaptureBody).id;
|
|
const inspection = await prisma.inspection.create({
|
|
data: { colonyId, userId: userIds.alice, date: new Date() },
|
|
});
|
|
await prisma.voiceCapture.update({
|
|
where: { id },
|
|
data: {
|
|
status: 'CONFIRMED',
|
|
inspectionId: inspection.id,
|
|
transcriptEnc: null,
|
|
},
|
|
});
|
|
await request(server())
|
|
.post(`/api/v1/voice-captures/${id}/discard`)
|
|
.set(as('alice'))
|
|
.expect(204);
|
|
const row = await prisma.voiceCapture.findUniqueOrThrow({
|
|
where: { id },
|
|
});
|
|
expect(row.status).toBe('CONFIRMED');
|
|
await prisma.voiceCapture.delete({ where: { id } });
|
|
await prisma.inspection.delete({ where: { id: inspection.id } });
|
|
});
|
|
|
|
it('discard-all touches only the caller and counts what it discarded', async () => {
|
|
const mine = [
|
|
((await sendText('alice').expect(201)).body as CaptureBody).id,
|
|
((await upload('alice').expect(201)).body as CaptureBody).id,
|
|
];
|
|
const done = ((await sendText('alice').expect(201)).body as CaptureBody)
|
|
.id;
|
|
await prisma.voiceCapture.update({
|
|
where: { id: done },
|
|
data: { status: 'EXPIRED', transcriptEnc: null },
|
|
});
|
|
const others = ((await sendText('bob').expect(201)).body as CaptureBody)
|
|
.id;
|
|
|
|
const response = await request(server())
|
|
.post('/api/v1/voice-captures/discard-all')
|
|
.set(as('alice'))
|
|
.expect(200);
|
|
expect(response.body).toEqual({ discarded: 2 });
|
|
|
|
const rows = await prisma.voiceCapture.findMany({
|
|
where: { id: { in: [...mine, done, others] } },
|
|
include: { audio: true },
|
|
});
|
|
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
for (const id of mine) {
|
|
expect(byId.get(id)).toMatchObject({
|
|
status: 'DISCARDED',
|
|
transcriptEnc: null,
|
|
audio: null,
|
|
});
|
|
}
|
|
expect(byId.get(done)?.status).toBe('EXPIRED');
|
|
expect(byId.get(others)).toMatchObject({ status: 'TRANSCRIBED' });
|
|
expect(byId.get(others)?.transcriptEnc).not.toBeNull();
|
|
});
|
|
});
|
|
});
|