mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 17:03:55 +10:00
chore: integrate improve-integration
This commit is contained in:
+21
-12
@@ -18,14 +18,23 @@
|
||||
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^4.0.8",
|
||||
"@ai-sdk/google": "^4.0.8",
|
||||
"@ai-sdk/openai": "^4.0.7",
|
||||
"@ai-sdk/openai-compatible": "^3.0.5",
|
||||
"@aws-sdk/client-s3": "^3.1079.0",
|
||||
"@orpc/client": "^1.14.6",
|
||||
"@orpc/experimental-ratelimit": "^1.14.6",
|
||||
"@orpc/server": "^1.14.6",
|
||||
"@ai-sdk/anthropic": "^4.0.10",
|
||||
"@ai-sdk/cerebras": "^3.0.6",
|
||||
"@ai-sdk/cohere": "^4.0.6",
|
||||
"@ai-sdk/deepseek": "^3.0.6",
|
||||
"@ai-sdk/fireworks": "^3.0.7",
|
||||
"@ai-sdk/google": "^4.0.10",
|
||||
"@ai-sdk/groq": "^4.0.6",
|
||||
"@ai-sdk/mistral": "^4.0.7",
|
||||
"@ai-sdk/openai": "^4.0.9",
|
||||
"@ai-sdk/openai-compatible": "^3.0.6",
|
||||
"@ai-sdk/perplexity": "^4.0.7",
|
||||
"@ai-sdk/togetherai": "^3.0.7",
|
||||
"@ai-sdk/xai": "^4.0.9",
|
||||
"@aws-sdk/client-s3": "^3.1081.0",
|
||||
"@orpc/client": "^1.14.7",
|
||||
"@orpc/experimental-ratelimit": "^1.14.7",
|
||||
"@orpc/server": "^1.14.7",
|
||||
"@reactive-resume/ai": "workspace:*",
|
||||
"@reactive-resume/auth": "workspace:*",
|
||||
"@reactive-resume/db": "workspace:*",
|
||||
@@ -34,14 +43,14 @@
|
||||
"@reactive-resume/resume": "workspace:*",
|
||||
"@reactive-resume/schema": "workspace:*",
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"ai": "^7.0.15",
|
||||
"ai": "^7.0.18",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.6.23",
|
||||
"drizzle-orm": "1.0.0-rc.4",
|
||||
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
||||
"es-toolkit": "^1.49.0",
|
||||
"ioredis": "^5.11.1",
|
||||
"ollama-ai-provider-v2": "^3.6.0",
|
||||
"ollama-ai-provider-v2": "^4.0.1",
|
||||
"react": "^19.2.7",
|
||||
"resumable-stream": "^2.2.12",
|
||||
"sharp": "^0.35.3",
|
||||
@@ -51,7 +60,7 @@
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260705.1",
|
||||
"typescript": "^6.0.3"
|
||||
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,3 +83,24 @@ describe("applicationDto zero-argument inputs", () => {
|
||||
expect(applicationDto.tags.input.parse(undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// Bulk operations cap `ids` at 200 to bound memory/DB work from a single call.
|
||||
describe("applicationDto bulk id caps", () => {
|
||||
const idsOfLength = (n: number) => Array.from({ length: n }, (_, i) => String(i));
|
||||
|
||||
it("rejects a bulkDelete ids array over the cap", () => {
|
||||
expect(applicationDto.bulkDelete.input.safeParse({ ids: idsOfLength(201) }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a bulkDelete ids array at the cap", () => {
|
||||
expect(applicationDto.bulkDelete.input.safeParse({ ids: idsOfLength(200) }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a bulkUpdate ids array over the cap", () => {
|
||||
expect(applicationDto.bulkUpdate.input.safeParse({ ids: idsOfLength(201) }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a bulkUpdate ids array at the cap", () => {
|
||||
expect(applicationDto.bulkUpdate.input.safeParse({ ids: idsOfLength(200) }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -162,7 +162,7 @@ export const applicationDto = {
|
||||
// Table bulk actions: move stage, archive/unarchive, add tags across a selection.
|
||||
bulkUpdate: {
|
||||
input: z.object({
|
||||
ids: z.array(z.string()).min(1),
|
||||
ids: z.array(z.string()).min(1).max(200, "Too many items in a single bulk operation"),
|
||||
status: applicationStatusSchema.optional(),
|
||||
archived: z.boolean().optional(),
|
||||
addTags: z.array(z.string()).optional(),
|
||||
@@ -171,7 +171,7 @@ export const applicationDto = {
|
||||
},
|
||||
|
||||
bulkDelete: {
|
||||
input: z.object({ ids: z.array(z.string()).min(1) }),
|
||||
input: z.object({ ids: z.array(z.string()).min(1).max(200, "Too many items in a single bulk operation") }),
|
||||
output: z.object({ deleted: z.number() }),
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import z from "zod";
|
||||
import { aiProviderSchema } from "@reactive-resume/ai/types";
|
||||
|
||||
export const providerInput = z.object({
|
||||
label: z.string().trim().min(1),
|
||||
provider: aiProviderSchema,
|
||||
model: z.string().trim().min(1),
|
||||
baseURL: z.string().trim().optional(),
|
||||
apiKey: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const updateProviderInput = providerInput
|
||||
.partial()
|
||||
.extend({ id: z.string(), enabled: z.boolean().optional() })
|
||||
.refine((input) => Object.keys(input).some((key) => key !== "id"), {
|
||||
message: "At least one field must be provided.",
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { updateProviderInput } from "./inputs";
|
||||
|
||||
describe("AI provider router input", () => {
|
||||
it("does not default baseURL on switch-only updates", () => {
|
||||
expect(updateProviderInput.parse({ id: "provider-1", enabled: false })).not.toHaveProperty("baseURL");
|
||||
});
|
||||
});
|
||||
@@ -2,26 +2,11 @@ import type { AiProviderResponse } from "./service";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { type } from "@orpc/server";
|
||||
import z from "zod";
|
||||
import { aiProviderSchema } from "@reactive-resume/ai/types";
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { aiRequestRateLimit } from "../../middleware/rate-limit";
|
||||
import { providerInput, updateProviderInput } from "./inputs";
|
||||
import { aiProvidersService } from "./service";
|
||||
|
||||
const providerInput = z.object({
|
||||
label: z.string().trim().min(1),
|
||||
provider: aiProviderSchema,
|
||||
model: z.string().trim().min(1),
|
||||
baseURL: z.string().trim().optional().default(""),
|
||||
apiKey: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
const updateProviderInput = providerInput
|
||||
.partial()
|
||||
.extend({ id: z.string(), enabled: z.boolean().optional() })
|
||||
.refine((input) => Object.keys(input).some((key) => key !== "id"), {
|
||||
message: "At least one field must be provided.",
|
||||
});
|
||||
|
||||
function isAgentEnvironmentUnavailable(error: unknown) {
|
||||
return error instanceof Error && error.message === "AGENT_ENVIRONMENT_UNAVAILABLE";
|
||||
}
|
||||
@@ -85,7 +70,7 @@ export const aiProvidersRouter = {
|
||||
label: input.label,
|
||||
provider: input.provider,
|
||||
model: input.model,
|
||||
baseURL: input.baseURL,
|
||||
...(input.baseURL !== undefined ? { baseURL: input.baseURL } : {}),
|
||||
apiKey: input.apiKey,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { dbMock, queryMock, queryState } = vi.hoisted(() => {
|
||||
const state = {
|
||||
rows: [] as unknown[],
|
||||
whereArg: undefined as unknown,
|
||||
orderByArgs: [] as unknown[],
|
||||
};
|
||||
const query = {
|
||||
from: vi.fn(() => query),
|
||||
where: vi.fn((arg: unknown) => {
|
||||
state.whereArg = arg;
|
||||
return query;
|
||||
}),
|
||||
orderBy: vi.fn((...args: unknown[]) => {
|
||||
state.orderByArgs = args;
|
||||
return query;
|
||||
}),
|
||||
limit: vi.fn(async () => state.rows),
|
||||
};
|
||||
|
||||
return {
|
||||
dbMock: { select: vi.fn(() => query) },
|
||||
queryMock: query,
|
||||
queryState: state,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@reactive-resume/db/client", () => ({ db: dbMock }));
|
||||
vi.mock("@reactive-resume/db/schema", () => ({
|
||||
aiProvider: {
|
||||
id: "ai_provider.id",
|
||||
userId: "ai_provider.user_id",
|
||||
label: "ai_provider.label",
|
||||
provider: "ai_provider.provider",
|
||||
model: "ai_provider.model",
|
||||
baseUrl: "ai_provider.base_url",
|
||||
encryptedApiKey: "ai_provider.encrypted_api_key",
|
||||
apiKeySalt: "ai_provider.api_key_salt",
|
||||
apiKeyHash: "ai_provider.api_key_hash",
|
||||
apiKeyPreview: "ai_provider.api_key_preview",
|
||||
testStatus: "ai_provider.test_status",
|
||||
testError: "ai_provider.test_error",
|
||||
lastTestedAt: "ai_provider.last_tested_at",
|
||||
lastUsedAt: "ai_provider.last_used_at",
|
||||
enabled: "ai_provider.enabled",
|
||||
createdAt: "ai_provider.created_at",
|
||||
updatedAt: "ai_provider.updated_at",
|
||||
},
|
||||
}));
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
and: (...conditions: unknown[]) => ({ type: "and", conditions }),
|
||||
asc: (value: unknown) => ({ type: "asc", value }),
|
||||
desc: (value: unknown) => ({ type: "desc", value }),
|
||||
eq: (left: unknown, right: unknown) => ({ type: "eq", left, right }),
|
||||
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ type: "sql", strings: [...strings], values }),
|
||||
}));
|
||||
vi.mock("../ai/credentials", () => ({
|
||||
assertCredentialEncryptionConfigured: vi.fn(),
|
||||
decryptCredential: vi.fn(() => "decrypted-key"),
|
||||
encryptCredential: vi.fn(),
|
||||
redactEncryptedCredential: vi.fn(() => ({
|
||||
apiKeyFingerprint: "fingerprint",
|
||||
apiKeyPreview: "sk-...test",
|
||||
})),
|
||||
}));
|
||||
vi.mock("../ai/service", () => ({ testConnection: vi.fn() }));
|
||||
vi.mock("../ai/url-policy", () => ({ resolveAiBaseUrl: vi.fn() }));
|
||||
|
||||
const { aiProvidersService } = await import("./service");
|
||||
|
||||
function providerRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "provider-1",
|
||||
userId: "user-1",
|
||||
label: "OpenAI",
|
||||
provider: "openai",
|
||||
model: "gpt-5-mini",
|
||||
baseUrl: null,
|
||||
encryptedApiKey: "encrypted-key",
|
||||
apiKeySalt: "salt",
|
||||
apiKeyHash: "hash",
|
||||
apiKeyPreview: "preview",
|
||||
testStatus: "success",
|
||||
testError: null,
|
||||
lastTestedAt: new Date("2026-07-01T00:00:00Z"),
|
||||
lastUsedAt: new Date("2026-07-07T00:00:00Z"),
|
||||
enabled: true,
|
||||
createdAt: new Date("2026-07-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-07-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("aiProvidersService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
queryState.rows = [];
|
||||
queryState.whereArg = undefined;
|
||||
queryState.orderByArgs = [];
|
||||
});
|
||||
|
||||
it("gets the first enabled and tested provider by creation order", async () => {
|
||||
queryState.rows = [providerRow({ id: "first-created" })];
|
||||
|
||||
await expect(aiProvidersService.getDefaultRunnable({ userId: "user-1" })).resolves.toMatchObject({
|
||||
id: "first-created",
|
||||
apiKey: "decrypted-key",
|
||||
});
|
||||
|
||||
expect(queryState.whereArg).toEqual({
|
||||
type: "and",
|
||||
conditions: [
|
||||
{ type: "eq", left: "ai_provider.user_id", right: "user-1" },
|
||||
{ type: "eq", left: "ai_provider.enabled", right: true },
|
||||
{ type: "eq", left: "ai_provider.test_status", right: "success" },
|
||||
],
|
||||
});
|
||||
expect(queryState.orderByArgs).toEqual([{ type: "asc", value: "ai_provider.created_at" }]);
|
||||
expect(queryMock.limit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -143,7 +143,7 @@ export const aiProvidersService = {
|
||||
eq(schema.aiProvider.testStatus, "success"),
|
||||
),
|
||||
)
|
||||
.orderBy(orderByLastUsedAtDescNullsLast(), asc(schema.aiProvider.createdAt))
|
||||
.orderBy(asc(schema.aiProvider.createdAt))
|
||||
.limit(1);
|
||||
|
||||
return provider
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { deflateRawSync } from "node:zlib";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
|
||||
const generateTextMock = vi.hoisted(() => vi.fn());
|
||||
const envMock = vi.hoisted(() => ({
|
||||
FLAG_ALLOW_UNSAFE_AI_BASE_URL: false,
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>();
|
||||
return { ...actual, generateText: generateTextMock };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
generateTextMock.mockReset();
|
||||
});
|
||||
|
||||
const u16 = (value: number) => {
|
||||
const buffer = Buffer.alloc(2);
|
||||
buffer.writeUInt16LE(value);
|
||||
return buffer;
|
||||
};
|
||||
|
||||
const u32 = (value: number) => {
|
||||
const buffer = Buffer.alloc(4);
|
||||
buffer.writeUInt32LE(value);
|
||||
return buffer;
|
||||
};
|
||||
|
||||
function createDocxBase64(text: string): string {
|
||||
const name = Buffer.from("word/document.xml");
|
||||
const data = Buffer.from(
|
||||
`<?xml version="1.0" encoding="UTF-8"?><w:document><w:body><w:p><w:r><w:t>${text}</w:t></w:r></w:p></w:body></w:document>`,
|
||||
);
|
||||
const compressed = deflateRawSync(data);
|
||||
const local = Buffer.concat([
|
||||
u32(0x04034b50),
|
||||
u16(20),
|
||||
u16(0),
|
||||
u16(8),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u32(0),
|
||||
u32(compressed.length),
|
||||
u32(data.length),
|
||||
u16(name.length),
|
||||
u16(0),
|
||||
name,
|
||||
compressed,
|
||||
]);
|
||||
const central = Buffer.concat([
|
||||
u32(0x02014b50),
|
||||
u16(20),
|
||||
u16(20),
|
||||
u16(0),
|
||||
u16(8),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u32(0),
|
||||
u32(compressed.length),
|
||||
u32(data.length),
|
||||
u16(name.length),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u32(0),
|
||||
u32(0),
|
||||
name,
|
||||
]);
|
||||
const eocd = Buffer.concat([
|
||||
u32(0x06054b50),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u16(1),
|
||||
u16(1),
|
||||
u32(central.length),
|
||||
u32(local.length),
|
||||
u16(0),
|
||||
]);
|
||||
|
||||
return Buffer.concat([local, central, eocd]).toString("base64");
|
||||
}
|
||||
|
||||
const { aiService } = await import("./service");
|
||||
|
||||
describe("AI DOCX parsing", () => {
|
||||
it("sends DOCX content as extracted text instead of an unsupported file part", async () => {
|
||||
generateTextMock.mockResolvedValue({ text: JSON.stringify(defaultResumeData) });
|
||||
|
||||
await aiService.parseDocx({
|
||||
provider: "openai-compatible",
|
||||
model: "test-model",
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://example.test/v1",
|
||||
mediaType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
file: { name: "resume.docx", data: createDocxBase64("Jane Doe & Co") },
|
||||
});
|
||||
|
||||
const request = generateTextMock.mock.calls[0]?.[0] as { messages: unknown[] };
|
||||
const messages = JSON.stringify(request.messages);
|
||||
|
||||
expect(messages).toContain("Jane Doe & Co");
|
||||
expect(messages).toContain("converted to plain text");
|
||||
expect(messages).not.toContain('"type":"file"');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,82 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { convertToModelMessages, modelMessageSchema } from "ai";
|
||||
|
||||
const envMock = vi.hoisted(() => ({
|
||||
FLAG_ALLOW_UNSAFE_AI_BASE_URL: false,
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function stubOpenAICompatibleResponse(response?: { content?: string; finishReason?: string }) {
|
||||
let requestBody: unknown;
|
||||
|
||||
const fetchMock = vi.fn(async (_input: unknown, init?: { body?: unknown }) => {
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as { max_tokens?: number };
|
||||
requestBody = body;
|
||||
const hasEnoughOutputTokens = (body.max_tokens ?? 0) >= 128;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "chatcmpl-test",
|
||||
object: "chat.completion",
|
||||
created: 1,
|
||||
model: "test-model",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: response?.content ?? (hasEnoughOutputTokens ? "1" : "") },
|
||||
finish_reason: response?.finishReason ?? (hasEnoughOutputTokens ? "stop" : "length"),
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
}),
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
return { fetchMock, getRequestBody: () => requestBody };
|
||||
}
|
||||
|
||||
const { testConnection } = await import("./service");
|
||||
|
||||
describe("AI chat service", () => {
|
||||
it("tests OpenAI-compatible providers without requiring structured output", async () => {
|
||||
const openAiCompatible = stubOpenAICompatibleResponse();
|
||||
|
||||
await expect(
|
||||
testConnection({
|
||||
provider: "openai-compatible",
|
||||
model: "test-model",
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://example.test/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(openAiCompatible.fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(openAiCompatible.getRequestBody()).not.toHaveProperty("response_format");
|
||||
expect(openAiCompatible.getRequestBody()).toMatchObject({ max_tokens: 128, temperature: 0 });
|
||||
});
|
||||
|
||||
it("explains when the provider test hits the output limit", async () => {
|
||||
stubOpenAICompatibleResponse({ content: "1. The connection works.", finishReason: "length" });
|
||||
|
||||
await expect(
|
||||
testConnection({
|
||||
provider: "openai-compatible",
|
||||
model: "test-model",
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://example.test/v1",
|
||||
}),
|
||||
).rejects.toThrow("The model returned too much text during the provider test.");
|
||||
});
|
||||
|
||||
it("keeps proposal tool history valid for follow-up chat messages", async () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
|
||||
@@ -2,12 +2,22 @@ import type { AIProvider } from "@reactive-resume/ai/types";
|
||||
import type { ResumeAnalysis } from "@reactive-resume/schema/resume/analysis";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { ModelMessage, UIMessage } from "ai";
|
||||
import { inflateRawSync } from "node:zlib";
|
||||
import { createAnthropic } from "@ai-sdk/anthropic";
|
||||
import { createCerebras } from "@ai-sdk/cerebras";
|
||||
import { createCohere } from "@ai-sdk/cohere";
|
||||
import { createDeepSeek } from "@ai-sdk/deepseek";
|
||||
import { createFireworks } from "@ai-sdk/fireworks";
|
||||
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
||||
import { createGroq } from "@ai-sdk/groq";
|
||||
import { createMistral } from "@ai-sdk/mistral";
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||
import { createPerplexity } from "@ai-sdk/perplexity";
|
||||
import { createTogetherAI } from "@ai-sdk/togetherai";
|
||||
import { createXai } from "@ai-sdk/xai";
|
||||
import { streamToEventIterator } from "@orpc/server";
|
||||
import { convertToModelMessages, createGateway, generateText, Output, stepCountIs, streamText, tool } from "ai";
|
||||
import { convertToModelMessages, createGateway, generateText, stepCountIs, streamText, tool } from "ai";
|
||||
import { createOllama } from "ollama-ai-provider-v2";
|
||||
import { match } from "ts-pattern";
|
||||
import { z } from "zod";
|
||||
@@ -72,6 +82,13 @@ type GetModelInput = {
|
||||
|
||||
const MAX_AI_FILE_BYTES = 10 * 1024 * 1024; // 10MB
|
||||
const MAX_AI_FILE_BASE64_CHARS = Math.ceil((MAX_AI_FILE_BYTES * 4) / 3) + 4;
|
||||
const TEST_CONNECTION_MAX_OUTPUT_TOKENS = 128;
|
||||
const DOCX_DOCUMENT_XML_PATH = "word/document.xml";
|
||||
const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
||||
const ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
|
||||
const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
|
||||
const ZIP_STORED_METHOD = 0;
|
||||
const ZIP_DEFLATED_METHOD = 8;
|
||||
|
||||
export function getModel(input: GetModelInput) {
|
||||
const { provider, model, apiKey } = input;
|
||||
@@ -83,6 +100,15 @@ export function getModel(input: GetModelInput) {
|
||||
.with("gemini", () => createGoogleGenerativeAI({ apiKey, baseURL }).languageModel(model))
|
||||
.with("vercel-ai-gateway", () => createGateway({ apiKey, baseURL }).languageModel(model))
|
||||
.with("openrouter", () => createOpenAICompatible({ name: "openrouter", apiKey, baseURL }).languageModel(model))
|
||||
.with("mistral", () => createMistral({ apiKey, baseURL }).languageModel(model))
|
||||
.with("cohere", () => createCohere({ apiKey, baseURL }).languageModel(model))
|
||||
.with("xai", () => createXai({ apiKey, baseURL }).languageModel(model))
|
||||
.with("groq", () => createGroq({ apiKey, baseURL }).languageModel(model))
|
||||
.with("deepseek", () => createDeepSeek({ apiKey, baseURL }).languageModel(model))
|
||||
.with("togetherai", () => createTogetherAI({ apiKey, baseURL }).languageModel(model))
|
||||
.with("fireworks", () => createFireworks({ apiKey, baseURL }).languageModel(model))
|
||||
.with("cerebras", () => createCerebras({ apiKey, baseURL }).languageModel(model))
|
||||
.with("perplexity", () => createPerplexity({ apiKey, baseURL }).languageModel(model))
|
||||
.with("openai-compatible", () =>
|
||||
createOpenAICompatible({ name: "openai-compatible", apiKey, baseURL }).languageModel(model),
|
||||
)
|
||||
@@ -123,11 +149,15 @@ export async function testConnection(input: TestConnectionInput): Promise<boolea
|
||||
|
||||
const result = await generateText({
|
||||
model: getModel(input),
|
||||
output: Output.choice({ options: [RESPONSE_OK] }),
|
||||
messages: [{ role: "user", content: `Respond only with JSON Object: { "result": "${RESPONSE_OK}" }` }],
|
||||
maxOutputTokens: TEST_CONNECTION_MAX_OUTPUT_TOKENS,
|
||||
temperature: 0,
|
||||
messages: [{ role: "user", content: `Respond only with the single character: ${RESPONSE_OK}` }],
|
||||
});
|
||||
|
||||
return result.output === RESPONSE_OK;
|
||||
if (result.text.trim() === RESPONSE_OK) return true;
|
||||
if (result.finishReason === "length") throw new Error("The model returned too much text during the provider test.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
type ParsePdfInput = z.infer<typeof aiCredentialsSchema> & {
|
||||
@@ -156,6 +186,20 @@ function buildResumeParsingMessages({ userPrompt, file, mediaType }: BuildResume
|
||||
];
|
||||
}
|
||||
|
||||
function buildResumeParsingTextMessages({ userPrompt, text }: { userPrompt: string; text: string }): ModelMessage[] {
|
||||
return [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${userPrompt}\n\nThe Microsoft Word file has been converted to plain text below.\n\n${text}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function parsePdf(input: ParsePdfInput): Promise<ResumeData> {
|
||||
const model = getModel(input);
|
||||
|
||||
@@ -177,17 +221,117 @@ type ParseDocxInput = z.infer<typeof aiCredentialsSchema> & {
|
||||
mediaType: "application/msword" | "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
};
|
||||
|
||||
function assertZipRange(buffer: Buffer, offset: number, length: number) {
|
||||
if (offset < 0 || length < 0 || offset + length > buffer.length) throw new Error("Invalid DOCX archive.");
|
||||
}
|
||||
|
||||
function findEndOfCentralDirectory(buffer: Buffer): number {
|
||||
const minOffset = Math.max(0, buffer.length - 0xffff - 22);
|
||||
|
||||
for (let offset = buffer.length - 22; offset >= minOffset; offset--) {
|
||||
if (buffer.readUInt32LE(offset) === ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) return offset;
|
||||
}
|
||||
|
||||
throw new Error("Invalid DOCX archive.");
|
||||
}
|
||||
|
||||
function readZipEntry(buffer: Buffer, entryName: string): Buffer {
|
||||
const eocdOffset = findEndOfCentralDirectory(buffer);
|
||||
assertZipRange(buffer, eocdOffset, 22);
|
||||
|
||||
const centralDirectorySize = buffer.readUInt32LE(eocdOffset + 12);
|
||||
const centralDirectoryOffset = buffer.readUInt32LE(eocdOffset + 16);
|
||||
assertZipRange(buffer, centralDirectoryOffset, centralDirectorySize);
|
||||
|
||||
let offset = centralDirectoryOffset;
|
||||
const endOffset = centralDirectoryOffset + centralDirectorySize;
|
||||
|
||||
while (offset < endOffset) {
|
||||
assertZipRange(buffer, offset, 46);
|
||||
if (buffer.readUInt32LE(offset) !== ZIP_CENTRAL_DIRECTORY_SIGNATURE) throw new Error("Invalid DOCX archive.");
|
||||
|
||||
const compressionMethod = buffer.readUInt16LE(offset + 10);
|
||||
const compressedSize = buffer.readUInt32LE(offset + 20);
|
||||
const fileNameLength = buffer.readUInt16LE(offset + 28);
|
||||
const extraFieldLength = buffer.readUInt16LE(offset + 30);
|
||||
const commentLength = buffer.readUInt16LE(offset + 32);
|
||||
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
|
||||
const fileNameOffset = offset + 46;
|
||||
assertZipRange(buffer, fileNameOffset, fileNameLength);
|
||||
|
||||
const fileName = buffer.toString("utf8", fileNameOffset, fileNameOffset + fileNameLength);
|
||||
|
||||
if (fileName === entryName) {
|
||||
assertZipRange(buffer, localHeaderOffset, 30);
|
||||
if (buffer.readUInt32LE(localHeaderOffset) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) {
|
||||
throw new Error("Invalid DOCX archive.");
|
||||
}
|
||||
|
||||
const localFileNameLength = buffer.readUInt16LE(localHeaderOffset + 26);
|
||||
const localExtraFieldLength = buffer.readUInt16LE(localHeaderOffset + 28);
|
||||
const dataOffset = localHeaderOffset + 30 + localFileNameLength + localExtraFieldLength;
|
||||
assertZipRange(buffer, dataOffset, compressedSize);
|
||||
|
||||
const compressed = buffer.subarray(dataOffset, dataOffset + compressedSize);
|
||||
if (compressionMethod === ZIP_STORED_METHOD) return compressed;
|
||||
if (compressionMethod === ZIP_DEFLATED_METHOD) return inflateRawSync(compressed);
|
||||
|
||||
throw new Error("Unsupported DOCX archive compression.");
|
||||
}
|
||||
|
||||
offset = fileNameOffset + fileNameLength + extraFieldLength + commentLength;
|
||||
}
|
||||
|
||||
throw new Error("DOCX document content not found.");
|
||||
}
|
||||
|
||||
function decodeXmlEntities(value: string): string {
|
||||
return value.replace(/&(#x[\da-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (entity, token: string) => {
|
||||
if (token === "amp") return "&";
|
||||
if (token === "lt") return "<";
|
||||
if (token === "gt") return ">";
|
||||
if (token === "quot") return '"';
|
||||
if (token === "apos") return "'";
|
||||
if (token.toLowerCase().startsWith("#x")) return String.fromCodePoint(Number.parseInt(token.slice(2), 16));
|
||||
if (token.startsWith("#")) return String.fromCodePoint(Number.parseInt(token.slice(1), 10));
|
||||
return entity;
|
||||
});
|
||||
}
|
||||
|
||||
function extractDocxText(file: z.infer<typeof fileInputSchema>): string {
|
||||
const documentXml = readZipEntry(Buffer.from(file.data, "base64"), DOCX_DOCUMENT_XML_PATH).toString("utf8");
|
||||
// ponytail: minimal OOXML body-text extraction; add a DOCX parser dependency if tracked changes matter.
|
||||
const text = decodeXmlEntities(
|
||||
documentXml
|
||||
.replace(/<w:tab\b[^>]*\/>/g, "\t")
|
||||
.replace(/<w:br\b[^>]*\/>/g, "\n")
|
||||
.replace(/<\/w:p>/g, "\n")
|
||||
.replace(/<[^>]+>/g, ""),
|
||||
)
|
||||
.replace(/\r/g, "")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
|
||||
if (!text) throw new Error("DOCX document content is empty.");
|
||||
return text;
|
||||
}
|
||||
|
||||
async function parseDocx(input: ParseDocxInput): Promise<ResumeData> {
|
||||
const model = getModel(input);
|
||||
const messages =
|
||||
input.mediaType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
? buildResumeParsingTextMessages({ userPrompt: docxParserUserPrompt, text: extractDocxText(input.file) })
|
||||
: buildResumeParsingMessages({
|
||||
userPrompt: docxParserUserPrompt,
|
||||
file: input.file,
|
||||
mediaType: input.mediaType,
|
||||
});
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
system: buildResumeParsingSystemPrompt(docxParserSystemPrompt),
|
||||
messages: buildResumeParsingMessages({
|
||||
userPrompt: docxParserUserPrompt,
|
||||
file: input.file,
|
||||
mediaType: input.mediaType,
|
||||
}),
|
||||
messages,
|
||||
}).catch((error: unknown) => logAndRethrow("Failed to generate the text with the model", error));
|
||||
|
||||
return parseAndValidateResumeJson(result.text);
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Characterization tests for the resume service. The goal is to pin down CURRENT behavior
|
||||
// (CRUD / lock / password / statistics branching) so later changes are deliberate. The DB
|
||||
// layer and side-effecting helpers are mocked; the branching in service.ts is what's under test.
|
||||
|
||||
const dbMock = vi.hoisted(() => ({
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
}));
|
||||
const hashMock = vi.hoisted(() => vi.fn());
|
||||
const compareMock = vi.hoisted(() => vi.fn());
|
||||
const publishResumeUpdatedMock = vi.hoisted(() => vi.fn());
|
||||
const grantResumeAccessMock = vi.hoisted(() => vi.fn());
|
||||
const hasResumeAccessMock = vi.hoisted(() => vi.fn());
|
||||
const storageDeleteMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@reactive-resume/db/client", () => ({ db: dbMock }));
|
||||
vi.mock("@reactive-resume/db/schema", () => ({
|
||||
resume: {
|
||||
id: "id",
|
||||
userId: "user_id",
|
||||
slug: "slug",
|
||||
name: "name",
|
||||
tags: "tags",
|
||||
data: "data",
|
||||
isPublic: "is_public",
|
||||
isLocked: "is_locked",
|
||||
password: "password",
|
||||
updatedAt: "updated_at",
|
||||
createdAt: "created_at",
|
||||
},
|
||||
resumeStatistics: {
|
||||
resumeId: "resume_id",
|
||||
views: "views",
|
||||
downloads: "downloads",
|
||||
lastViewedAt: "last_viewed_at",
|
||||
lastDownloadedAt: "last_downloaded_at",
|
||||
},
|
||||
resumeStatisticsDaily: {
|
||||
resumeId: "resume_id",
|
||||
date: "date",
|
||||
views: "views",
|
||||
downloads: "downloads",
|
||||
},
|
||||
resumeVersion: {
|
||||
id: "id",
|
||||
resumeId: "resume_id",
|
||||
userId: "user_id",
|
||||
data: "data",
|
||||
label: "label",
|
||||
createdAt: "created_at",
|
||||
},
|
||||
resumeAnalysis: { resumeId: "resume_id", analysis: "analysis" },
|
||||
user: { id: "id", username: "username" },
|
||||
}));
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
and: (...a: unknown[]) => a,
|
||||
arrayContains: (...a: unknown[]) => a,
|
||||
asc: (x: unknown) => x,
|
||||
desc: (x: unknown) => x,
|
||||
eq: (...a: unknown[]) => a,
|
||||
gte: (...a: unknown[]) => a,
|
||||
isNotNull: (...a: unknown[]) => a,
|
||||
notInArray: (...a: unknown[]) => a,
|
||||
sql: Object.assign((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values }), {
|
||||
join: (values: unknown[]) => values,
|
||||
}),
|
||||
}));
|
||||
vi.mock("bcrypt", () => ({ hash: hashMock, compare: compareMock }));
|
||||
vi.mock("./events", () => ({ publishResumeUpdated: publishResumeUpdatedMock }));
|
||||
vi.mock("./access", () => ({
|
||||
grantResumeAccess: grantResumeAccessMock,
|
||||
hasResumeAccess: hasResumeAccessMock,
|
||||
}));
|
||||
vi.mock("../storage/service", () => ({
|
||||
getStorageService: () => ({ delete: storageDeleteMock }),
|
||||
}));
|
||||
|
||||
const { resumeService } = await import("./service");
|
||||
|
||||
// A `db.update(...).set(...).where(...).returning(...)` chain that resolves to `rows`.
|
||||
const createUpdateChain = (rows: unknown[]) => {
|
||||
const returning = vi.fn(() => Promise.resolve(rows));
|
||||
const where = vi.fn(() => ({ returning }));
|
||||
const set = vi.fn(() => ({ where }));
|
||||
return { chain: { set }, set, where, returning };
|
||||
};
|
||||
|
||||
// A `db.select(...).from(...).where(...)` chain that resolves to `rows`.
|
||||
const createSelectChain = (rows: unknown[]) => ({
|
||||
from: () => ({ where: () => Promise.resolve(rows) }),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
dbMock.select.mockReset();
|
||||
dbMock.insert.mockReset();
|
||||
dbMock.update.mockReset();
|
||||
dbMock.delete.mockReset();
|
||||
dbMock.transaction.mockReset();
|
||||
hashMock.mockReset();
|
||||
compareMock.mockReset();
|
||||
publishResumeUpdatedMock.mockReset();
|
||||
grantResumeAccessMock.mockReset();
|
||||
hasResumeAccessMock.mockReset();
|
||||
storageDeleteMock.mockReset();
|
||||
hashMock.mockResolvedValue("hashed-password");
|
||||
publishResumeUpdatedMock.mockResolvedValue(undefined);
|
||||
storageDeleteMock.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("imports", () => {
|
||||
expect(resumeService).toBeDefined();
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("throws RESUME_LOCKED when the pre-read reports the resume is locked", async () => {
|
||||
dbMock.select.mockReturnValueOnce(createSelectChain([{ isLocked: true }]));
|
||||
|
||||
await expect(resumeService.update({ id: "r1", userId: "u1", name: "New" })).rejects.toMatchObject({
|
||||
code: "RESUME_LOCKED",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the updated row on success", async () => {
|
||||
dbMock.select.mockReturnValueOnce(createSelectChain([{ isLocked: false }]));
|
||||
const row = {
|
||||
id: "r1",
|
||||
name: "New",
|
||||
slug: "slug",
|
||||
tags: [],
|
||||
data: {},
|
||||
isPublic: false,
|
||||
isLocked: false,
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
hasPassword: false,
|
||||
};
|
||||
dbMock.update.mockReturnValueOnce(createUpdateChain([row]).chain);
|
||||
|
||||
const result = await resumeService.update({ id: "r1", userId: "u1", name: "New" });
|
||||
|
||||
expect(result).toEqual(row);
|
||||
expect(publishResumeUpdatedMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when the UPDATE ... RETURNING matches no row", async () => {
|
||||
dbMock.select.mockReturnValueOnce(createSelectChain([{ isLocked: false }]));
|
||||
dbMock.update.mockReturnValueOnce(createUpdateChain([]).chain);
|
||||
|
||||
await expect(resumeService.update({ id: "r1", userId: "u1", name: "New" })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a resume_slug_user_id_unique violation to RESUME_SLUG_ALREADY_EXISTS", async () => {
|
||||
dbMock.select.mockReturnValueOnce(createSelectChain([{ isLocked: false }]));
|
||||
dbMock.update.mockReturnValueOnce({
|
||||
set: () => ({
|
||||
where: () => ({
|
||||
returning: () => {
|
||||
const error = new Error("duplicate key") as Error & { cause: { constraint: string } };
|
||||
error.cause = { constraint: "resume_slug_user_id_unique" };
|
||||
return Promise.reject(error);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(resumeService.update({ id: "r1", userId: "u1", slug: "taken" })).rejects.toMatchObject({
|
||||
code: "RESUME_SLUG_ALREADY_EXISTS",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("setLocked", () => {
|
||||
it("resolves and notifies on success (mutation: lock)", async () => {
|
||||
dbMock.update.mockReturnValueOnce(
|
||||
createUpdateChain([{ id: "r1", updatedAt: new Date("2026-01-01T00:00:00Z") }]).chain,
|
||||
);
|
||||
|
||||
await expect(resumeService.setLocked({ id: "r1", userId: "u1", isLocked: true })).resolves.toBeUndefined();
|
||||
|
||||
expect(publishResumeUpdatedMock).toHaveBeenCalledTimes(1);
|
||||
expect(publishResumeUpdatedMock).toHaveBeenCalledWith(expect.objectContaining({ mutation: "lock" }));
|
||||
});
|
||||
|
||||
// Plan 003: no matching row now rejects with NOT_FOUND (previously a silent resolve).
|
||||
it("throws NOT_FOUND when no row matches, without notifying", async () => {
|
||||
dbMock.update.mockReturnValueOnce(createUpdateChain([]).chain);
|
||||
|
||||
await expect(resumeService.setLocked({ id: "r1", userId: "u1", isLocked: true })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
expect(publishResumeUpdatedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPassword", () => {
|
||||
it("hashes the password then resolves and notifies on success (mutation: password)", async () => {
|
||||
dbMock.update.mockReturnValueOnce(
|
||||
createUpdateChain([{ id: "r1", updatedAt: new Date("2026-01-01T00:00:00Z") }]).chain,
|
||||
);
|
||||
|
||||
await expect(resumeService.setPassword({ id: "r1", userId: "u1", password: "secret" })).resolves.toBeUndefined();
|
||||
|
||||
expect(hashMock).toHaveBeenCalledWith("secret", 10);
|
||||
expect(publishResumeUpdatedMock).toHaveBeenCalledTimes(1);
|
||||
expect(publishResumeUpdatedMock).toHaveBeenCalledWith(expect.objectContaining({ mutation: "password" }));
|
||||
});
|
||||
|
||||
// Plan 003: no matching row now rejects with NOT_FOUND (previously a silent resolve).
|
||||
it("throws NOT_FOUND when no row matches, without notifying", async () => {
|
||||
dbMock.update.mockReturnValueOnce(createUpdateChain([]).chain);
|
||||
|
||||
await expect(resumeService.setPassword({ id: "r1", userId: "u1", password: "secret" })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
expect(publishResumeUpdatedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removePassword", () => {
|
||||
it("resolves and notifies on success (mutation: password)", async () => {
|
||||
dbMock.update.mockReturnValueOnce(
|
||||
createUpdateChain([{ id: "r1", updatedAt: new Date("2026-01-01T00:00:00Z") }]).chain,
|
||||
);
|
||||
|
||||
await expect(resumeService.removePassword({ id: "r1", userId: "u1" })).resolves.toBeUndefined();
|
||||
|
||||
expect(publishResumeUpdatedMock).toHaveBeenCalledTimes(1);
|
||||
expect(publishResumeUpdatedMock).toHaveBeenCalledWith(expect.objectContaining({ mutation: "password" }));
|
||||
});
|
||||
|
||||
// Plan 003: no matching row now rejects with NOT_FOUND (previously a silent resolve).
|
||||
it("throws NOT_FOUND when no row matches, without notifying", async () => {
|
||||
dbMock.update.mockReturnValueOnce(createUpdateChain([]).chain);
|
||||
|
||||
await expect(resumeService.removePassword({ id: "r1", userId: "u1" })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
expect(publishResumeUpdatedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyPassword", () => {
|
||||
it("throws INVALID_PASSWORD when no matching row is found", async () => {
|
||||
dbMock.select.mockReturnValueOnce({
|
||||
from: () => ({ innerJoin: () => ({ where: () => Promise.resolve([]) }) }),
|
||||
});
|
||||
|
||||
await expect(resumeService.verifyPassword({ slug: "s", username: "u", password: "p" })).rejects.toMatchObject({
|
||||
code: "INVALID_PASSWORD",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws INVALID_PASSWORD when bcrypt.compare returns false", async () => {
|
||||
dbMock.select.mockReturnValueOnce({
|
||||
from: () => ({ innerJoin: () => ({ where: () => Promise.resolve([{ id: "r1", password: "hash" }]) }) }),
|
||||
});
|
||||
compareMock.mockResolvedValueOnce(false);
|
||||
|
||||
await expect(resumeService.verifyPassword({ slug: "s", username: "u", password: "p" })).rejects.toMatchObject({
|
||||
code: "INVALID_PASSWORD",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns true and grants access when bcrypt.compare returns true", async () => {
|
||||
dbMock.select.mockReturnValueOnce({
|
||||
from: () => ({ innerJoin: () => ({ where: () => Promise.resolve([{ id: "r1", password: "hash" }]) }) }),
|
||||
});
|
||||
compareMock.mockResolvedValueOnce(true);
|
||||
const responseHeaders = new Headers();
|
||||
|
||||
const result = await resumeService.verifyPassword({
|
||||
slug: "s",
|
||||
username: "u",
|
||||
password: "p",
|
||||
responseHeaders,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(grantResumeAccessMock).toHaveBeenCalledWith(responseHeaders, "r1", "hash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
const runTransaction = (tx: unknown) => {
|
||||
dbMock.transaction.mockImplementationOnce(async (cb: (tx: unknown) => Promise<unknown>) => cb(tx));
|
||||
};
|
||||
|
||||
it("throws NOT_FOUND when the row is missing", async () => {
|
||||
runTransaction({
|
||||
select: () => createSelectChain([]),
|
||||
});
|
||||
|
||||
await expect(resumeService.delete({ id: "r1", userId: "u1" })).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("throws RESUME_LOCKED when the row is locked", async () => {
|
||||
runTransaction({
|
||||
select: () => createSelectChain([{ isLocked: true }]),
|
||||
});
|
||||
|
||||
await expect(resumeService.delete({ id: "r1", userId: "u1" })).rejects.toMatchObject({
|
||||
code: "RESUME_LOCKED",
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes storage for screenshot and pdf keys on success", async () => {
|
||||
const deleteWhere = vi.fn(() => Promise.resolve());
|
||||
runTransaction({
|
||||
select: () => createSelectChain([{ isLocked: false }]),
|
||||
delete: () => ({ where: deleteWhere }),
|
||||
});
|
||||
|
||||
await resumeService.delete({ id: "r1", userId: "u1" });
|
||||
|
||||
expect(deleteWhere).toHaveBeenCalledTimes(1);
|
||||
expect(storageDeleteMock).toHaveBeenCalledWith("uploads/u1/screenshots/r1");
|
||||
expect(storageDeleteMock).toHaveBeenCalledWith("uploads/u1/pdfs/r1");
|
||||
expect(publishResumeUpdatedMock).toHaveBeenCalledWith(expect.objectContaining({ mutation: "delete" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("statistics.increment", () => {
|
||||
it("writes both resumeStatistics and resumeStatisticsDaily inside one transaction", async () => {
|
||||
const values = vi.fn(() => ({ onConflictDoUpdate: vi.fn(() => Promise.resolve()) }));
|
||||
const txInsert = vi.fn(() => ({ values }));
|
||||
dbMock.transaction.mockImplementationOnce(async (cb: (tx: unknown) => Promise<unknown>) =>
|
||||
cb({ insert: txInsert }),
|
||||
);
|
||||
|
||||
await resumeService.statistics.increment({ id: "r1", views: true });
|
||||
|
||||
expect(dbMock.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(txInsert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { getStorageService } from "../storage/service";
|
||||
import { grantResumeAccess, hasResumeAccess } from "./access";
|
||||
import { assertCanView, isOwner, redactResumeForViewer, shouldCountForStatistics } from "./access-policy";
|
||||
import { publishResumeUpdated } from "./events";
|
||||
import { clientKeyFromHeaders, shouldCountView } from "./view-dedup";
|
||||
|
||||
type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
|
||||
@@ -503,7 +504,10 @@ export const resumeService = {
|
||||
}
|
||||
|
||||
if (shouldCountForStatistics(resume, viewer)) {
|
||||
await resumeService.statistics.increment({ id: resume.id, views: true });
|
||||
const key = `${resume.id}:${clientKeyFromHeaders(input.requestHeaders)}`;
|
||||
if (shouldCountView(key, Date.now())) {
|
||||
await resumeService.statistics.increment({ id: resume.id, views: true });
|
||||
}
|
||||
}
|
||||
|
||||
return toSharedResumeResponse(redactResumeForViewer(resume, isOwner(resume, viewer)), resume.hasPassword);
|
||||
@@ -667,7 +671,7 @@ export const resumeService = {
|
||||
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)))
|
||||
.returning({ id: schema.resume.id, updatedAt: schema.resume.updatedAt });
|
||||
|
||||
if (!resume) return;
|
||||
if (!resume) throw new ORPCError("NOT_FOUND");
|
||||
|
||||
await notifyResumeUpdated({
|
||||
type: "resume.updated",
|
||||
@@ -687,7 +691,7 @@ export const resumeService = {
|
||||
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)))
|
||||
.returning({ id: schema.resume.id, updatedAt: schema.resume.updatedAt });
|
||||
|
||||
if (!resume) return;
|
||||
if (!resume) throw new ORPCError("NOT_FOUND");
|
||||
|
||||
await notifyResumeUpdated({
|
||||
type: "resume.updated",
|
||||
@@ -730,7 +734,7 @@ export const resumeService = {
|
||||
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)))
|
||||
.returning({ id: schema.resume.id, updatedAt: schema.resume.updatedAt });
|
||||
|
||||
if (!resume) return;
|
||||
if (!resume) throw new ORPCError("NOT_FOUND");
|
||||
|
||||
await notifyResumeUpdated({
|
||||
type: "resume.updated",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clientKeyFromHeaders, shouldCountView } from "./view-dedup";
|
||||
|
||||
// `seen` is module-level state shared across tests, so each test uses a unique key.
|
||||
const WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
describe("shouldCountView", () => {
|
||||
it("counts the first view, skips repeats within the window, counts again after it", () => {
|
||||
const key = "resume-1:viewer-a";
|
||||
const t0 = 1_000_000;
|
||||
|
||||
expect(shouldCountView(key, t0)).toBe(true);
|
||||
expect(shouldCountView(key, t0 + 1)).toBe(false);
|
||||
expect(shouldCountView(key, t0 + WINDOW_MS - 1)).toBe(false);
|
||||
// Once now is past the window, the same key counts again.
|
||||
expect(shouldCountView(key, t0 + WINDOW_MS + 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats different keys independently", () => {
|
||||
const t0 = 2_000_000;
|
||||
|
||||
expect(shouldCountView("resume-2:viewer-a", t0)).toBe(true);
|
||||
expect(shouldCountView("resume-2:viewer-b", t0)).toBe(true);
|
||||
expect(shouldCountView("resume-2:viewer-a", t0 + 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clientKeyFromHeaders", () => {
|
||||
it("derives distinct keys from distinct trusted-IP headers", () => {
|
||||
const a = clientKeyFromHeaders(new Headers({ "X-Forwarded-For": "1.1.1.1" }));
|
||||
const b = clientKeyFromHeaders(new Headers({ "X-Forwarded-For": "2.2.2.2" }));
|
||||
|
||||
expect(a).toBe("ip:1.1.1.1");
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("uses the first IP from a comma-delimited proxy chain", () => {
|
||||
const key = clientKeyFromHeaders(new Headers({ "X-Forwarded-For": "3.3.3.3, 10.0.0.1" }));
|
||||
expect(key).toBe("ip:3.3.3.3");
|
||||
});
|
||||
|
||||
it("falls back to a stable user-agent fingerprint when no trusted IP header is present", () => {
|
||||
const headers = new Headers({ "user-agent": "Mozilla/5.0", "accept-language": "en-US,en" });
|
||||
|
||||
const first = clientKeyFromHeaders(headers);
|
||||
const second = clientKeyFromHeaders(headers);
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(first.startsWith("fp:")).toBe(true);
|
||||
// A different UA yields a different fallback key.
|
||||
expect(clientKeyFromHeaders(new Headers({ "user-agent": "curl/8" }))).not.toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit";
|
||||
|
||||
// ponytail: in-memory per-process dedup window. Single-instance is the default deploy; for
|
||||
// multi-instance, swap the Map for a Redis SETNX+EXPIRE keyed the same way (REDIS_URL already
|
||||
// exists in env). Upgrade only if you scale out — each instance dedups independently otherwise.
|
||||
const WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
const MAX_ENTRIES = 50_000; // bound the Map; prune expired entries before growing past this.
|
||||
|
||||
const seen = new Map<string, number>(); // key -> expiry timestamp
|
||||
|
||||
/**
|
||||
* Returns `true` at most once per `key` per window. `now` is a parameter (not `Date.now()`)
|
||||
* so callers stay testable with a driven clock.
|
||||
*/
|
||||
export function shouldCountView(key: string, now: number): boolean {
|
||||
const expiry = seen.get(key);
|
||||
if (expiry !== undefined && expiry > now) return false;
|
||||
|
||||
if (seen.size >= MAX_ENTRIES) {
|
||||
for (const [k, exp] of seen) {
|
||||
if (exp <= now) seen.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
seen.set(key, now + WINDOW_MS);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mirrors the rate-limit middleware's client-key derivation so dedup and rate limiting agree on
|
||||
// "who is this viewer": trusted proxy IP first, then a user-agent + language fingerprint fallback.
|
||||
export function clientKeyFromHeaders(headers: Headers): string {
|
||||
for (const headerName of TRUSTED_IP_HEADERS) {
|
||||
const raw = headers.get(headerName)?.trim();
|
||||
if (!raw) continue;
|
||||
|
||||
// Some proxies provide a comma-delimited chain; the first item is the original client.
|
||||
const ip = raw.split(",")[0]?.trim();
|
||||
if (ip) return `ip:${ip}`;
|
||||
}
|
||||
|
||||
const userAgent = headers.get("user-agent")?.trim() ?? "unknown";
|
||||
const language = headers.get("accept-language")?.split(",")[0]?.trim() ?? "none";
|
||||
|
||||
return `fp:${userAgent.slice(0, 64)}:${language.slice(0, 16)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user