feat(extraction): add rate limiting + 21 schema tests
- Rate limiting on extract routes (30 req/min per IP via @fastify/rate-limit) - 13 tests for ExtractRequestSchema, BatchExtractRequestSchema, ExtractionExampleSchema - 8 tests for ExtractionTaskSchema, CreateTaskSchema, UpdateTaskSchema - All 21 tests passing, pnpm build clean
This commit is contained in:
parent
4b4720aebd
commit
0a87d1937b
@ -1,10 +1,17 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import rateLimit from '@fastify/rate-limit';
|
||||||
|
|
||||||
import { ExtractRequestSchema, BatchExtractRequestSchema } from './types.js';
|
import { ExtractRequestSchema, BatchExtractRequestSchema } from './types.js';
|
||||||
import { sidecarExtract, sidecarExtractBatch, sidecarHealth } from '../../lib/python-bridge.js';
|
import { sidecarExtract, sidecarExtractBatch, sidecarHealth } from '../../lib/python-bridge.js';
|
||||||
import { BadRequestError } from '../../lib/errors.js';
|
import { BadRequestError } from '../../lib/errors.js';
|
||||||
|
|
||||||
export async function extractRoutes(app: FastifyInstance) {
|
export async function extractRoutes(app: FastifyInstance) {
|
||||||
|
// Rate limiting for extraction endpoints — 30 req/min per IP (configurable)
|
||||||
|
await app.register(rateLimit, {
|
||||||
|
max: 30,
|
||||||
|
timeWindow: '1 minute',
|
||||||
|
keyGenerator: req => req.ip,
|
||||||
|
});
|
||||||
/**
|
/**
|
||||||
* POST /extract — Single document extraction.
|
* POST /extract — Single document extraction.
|
||||||
*/
|
*/
|
||||||
|
|||||||
132
services/extraction-service/src/modules/extract/types.test.ts
Normal file
132
services/extraction-service/src/modules/extract/types.test.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ExtractRequestSchema,
|
||||||
|
BatchExtractRequestSchema,
|
||||||
|
ExtractionExampleSchema,
|
||||||
|
ExtractionResultSchema,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
describe('ExtractionExampleSchema', () => {
|
||||||
|
it('accepts valid example', () => {
|
||||||
|
const result = ExtractionExampleSchema.safeParse({
|
||||||
|
text: 'John said ship by Friday',
|
||||||
|
extractions: [{ extraction_class: 'deadline', extraction_text: 'ship by Friday' }],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty text', () => {
|
||||||
|
const result = ExtractionExampleSchema.safeParse({
|
||||||
|
text: '',
|
||||||
|
extractions: [],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts extractions with attributes', () => {
|
||||||
|
const result = ExtractionExampleSchema.safeParse({
|
||||||
|
text: 'test',
|
||||||
|
extractions: [
|
||||||
|
{
|
||||||
|
extraction_class: 'emotion',
|
||||||
|
extraction_text: 'stressed',
|
||||||
|
attributes: { valence: 'negative' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ExtractionResultSchema', () => {
|
||||||
|
it('accepts result with offsets', () => {
|
||||||
|
const result = ExtractionResultSchema.safeParse({
|
||||||
|
extraction_class: 'action_item',
|
||||||
|
extraction_text: 'call the dentist',
|
||||||
|
start_offset: 10,
|
||||||
|
end_offset: 26,
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts result without optional fields', () => {
|
||||||
|
const result = ExtractionResultSchema.safeParse({
|
||||||
|
extraction_class: 'topic',
|
||||||
|
extraction_text: 'meeting',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ExtractRequestSchema', () => {
|
||||||
|
it('accepts minimal valid request', () => {
|
||||||
|
const result = ExtractRequestSchema.safeParse({
|
||||||
|
text: 'Hello world',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts full request with all options', () => {
|
||||||
|
const result = ExtractRequestSchema.safeParse({
|
||||||
|
text: 'John said ship by Friday. Sarah will test.',
|
||||||
|
taskId: 'transcript-extraction',
|
||||||
|
modelId: 'gemini-2.5-flash',
|
||||||
|
examples: [
|
||||||
|
{
|
||||||
|
text: 'example text',
|
||||||
|
extractions: [{ extraction_class: 'person', extraction_text: 'John' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
options: {
|
||||||
|
extractionPasses: 2,
|
||||||
|
maxWorkers: 5,
|
||||||
|
maxCharBuffer: 1000,
|
||||||
|
},
|
||||||
|
productId: 'lysnrai',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty text', () => {
|
||||||
|
const result = ExtractRequestSchema.safeParse({ text: '' });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects text exceeding 50,000 chars', () => {
|
||||||
|
const result = ExtractRequestSchema.safeParse({
|
||||||
|
text: 'a'.repeat(50_001),
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid options', () => {
|
||||||
|
const result = ExtractRequestSchema.safeParse({
|
||||||
|
text: 'test',
|
||||||
|
options: { extractionPasses: 10 },
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BatchExtractRequestSchema', () => {
|
||||||
|
it('accepts valid batch', () => {
|
||||||
|
const result = BatchExtractRequestSchema.safeParse({
|
||||||
|
inputs: [{ text: 'first document' }, { text: 'second document', taskId: 'triage' }],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty inputs', () => {
|
||||||
|
const result = BatchExtractRequestSchema.safeParse({ inputs: [] });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects batch exceeding 50 inputs', () => {
|
||||||
|
const inputs = Array.from({ length: 51 }, (_, i) => ({
|
||||||
|
text: `document ${i}`,
|
||||||
|
}));
|
||||||
|
const result = BatchExtractRequestSchema.safeParse({ inputs });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
87
services/extraction-service/src/modules/tasks/types.test.ts
Normal file
87
services/extraction-service/src/modules/tasks/types.test.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import { ExtractionTaskSchema, CreateTaskSchema, UpdateTaskSchema } from './types.js';
|
||||||
|
|
||||||
|
describe('ExtractionTaskSchema', () => {
|
||||||
|
it('accepts valid task', () => {
|
||||||
|
const result = ExtractionTaskSchema.safeParse({
|
||||||
|
id: 'transcript-extraction',
|
||||||
|
name: 'Transcript Extraction',
|
||||||
|
prompt: 'Extract entities from transcripts.',
|
||||||
|
classes: ['action_item', 'decision', 'person'],
|
||||||
|
builtIn: true,
|
||||||
|
productId: 'lysnrai',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects task without required fields', () => {
|
||||||
|
const result = ExtractionTaskSchema.safeParse({
|
||||||
|
id: 'test',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults builtIn to false', () => {
|
||||||
|
const result = ExtractionTaskSchema.safeParse({
|
||||||
|
id: 'custom-task',
|
||||||
|
name: 'Custom',
|
||||||
|
prompt: 'Extract stuff.',
|
||||||
|
classes: ['thing'],
|
||||||
|
productId: 'lysnrai',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.builtIn).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CreateTaskSchema', () => {
|
||||||
|
it('accepts minimal create input', () => {
|
||||||
|
const result = CreateTaskSchema.safeParse({
|
||||||
|
id: 'my-task',
|
||||||
|
name: 'My Task',
|
||||||
|
prompt: 'Extract things.',
|
||||||
|
classes: ['thing'],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts create with examples', () => {
|
||||||
|
const result = CreateTaskSchema.safeParse({
|
||||||
|
id: 'my-task',
|
||||||
|
name: 'My Task',
|
||||||
|
prompt: 'Extract things.',
|
||||||
|
classes: ['thing'],
|
||||||
|
examples: [
|
||||||
|
{
|
||||||
|
text: 'sample text',
|
||||||
|
extractions: [{ extraction_class: 'thing', extraction_text: 'sample' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UpdateTaskSchema', () => {
|
||||||
|
it('accepts partial update', () => {
|
||||||
|
const result = UpdateTaskSchema.safeParse({
|
||||||
|
name: 'Updated Name',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts empty update', () => {
|
||||||
|
const result = UpdateTaskSchema.safeParse({});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid classes', () => {
|
||||||
|
const result = UpdateTaskSchema.safeParse({
|
||||||
|
classes: [''],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user