From bee271ee186168baa92909a24105eac742b9ce49 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Sat, 7 Feb 2026 15:12:48 +0100 Subject: [PATCH] refactor: add AI parser, provider, and file-upload packages --- apps/server/src/config/env.validation.ts | 37 +- apps/server/src/modules/app.module.ts | 2 + apps/server/src/modules/app/app.module.ts | 2 + .../src/modules/app/health.controller.ts | 12 + .../__tests__/cv-parser.service.spec.ts | 333 ++++++++++++++ .../__tests__/entity-resolver.service.spec.ts | 345 +++++++++++++++ .../__tests__/fixtures/mock-ai-responses.ts | 172 ++++++++ .../cv-parser/__tests__/fixtures/sample-cv.md | 62 +++ .../__tests__/fixtures/sample-cv.txt | 42 ++ .../src/modules/cv-parser/cv-parser.module.ts | 67 +++ .../modules/cv-parser/cv-parser.resolver.ts | 54 +++ .../modules/cv-parser/cv-parser.service.ts | 170 +++++++ .../cv-parser/entity-resolver.service.ts | 120 +++++ .../cv-parser/file-upload.controller.ts | 74 ++++ .../modules/cv-parser/persistence.service.ts | 260 +++++++++++ .../modules/cv-parser/types/draft.types.ts | 162 +++++++ .../modules/cv-parser/types/input.types.ts | 79 ++++ apps/server/test/auth-integration.e2e-spec.ts | 24 +- apps/server/test/auth.e2e-spec.ts | 13 +- .../test/coverage-unit/lcov-report/index.html | 2 +- .../test/cv-upload-integration.e2e-spec.ts | 349 +++++++++++++++ apps/server/test/cv-upload.e2e-spec.ts | 313 +++++++++++++ apps/server/test/cv-upload.integration.ts | 416 ++++++++++++++++++ apps/server/test/jest-e2e.json | 2 +- apps/server/test/jest-unit.json | 2 +- .../test/queries/login-mutation.graphql | 4 +- .../test/queries/register-mutation.graphql | 4 +- apps/server/test/test-utils.ts | 18 +- apps/server/vitest.config.ts | 12 + biome.json | 9 +- packages/ai-parser/jest.config.ts | 31 ++ packages/ai-parser/package.json | 29 ++ .../src/__tests__/ai-parser.service.spec.ts | 407 +++++++++++++++++ .../ai-parser/src/ai-parser.service.test.ts | 162 +++++++ packages/ai-parser/src/ai-parser.service.ts | 93 ++++ packages/ai-parser/src/cv-parser.module.ts | 24 + packages/ai-parser/src/index.ts | 18 + packages/ai-parser/src/prompts.ts | 61 +++ packages/ai-parser/src/schemas.ts | 66 +++ packages/ai-parser/tsconfig.json | 8 + packages/ai-provider/TEST_SETUP.md | 70 +++ packages/ai-provider/jest.config.ts | 25 ++ packages/ai-provider/package.json | 30 ++ packages/ai-provider/src/ai.module.ts | 54 +++ packages/ai-provider/src/index.ts | 13 + packages/ai-provider/src/providers/index.ts | 1 + .../llama-cpp.provider.integration.test.ts | 97 ++++ .../src/providers/llama-cpp.provider.ts | 117 +++++ packages/ai-provider/src/types.ts | 69 +++ packages/ai-provider/tsconfig.json | 8 + .../authorization/authorization.service.ts | 2 +- .../authorization/policy-registry.service.ts | 2 +- packages/auth/src/config/jwt.config.ts | 2 +- packages/auth/src/cookie/cookie.service.ts | 8 +- packages/auth/src/guards/jwt-auth.guard.ts | 4 +- .../auth/src/guards/verified-scope.guard.ts | 2 +- .../src/identity-provider-registry.service.ts | 2 +- .../password-reset-email.listener.ts | 2 +- .../registration-attempt-email.listener.ts | 2 +- .../listeners/verification-email.listener.ts | 2 +- .../password-authentication.service.ts | 12 +- .../password/password-identity-provider.ts | 2 +- .../password/password-provider.module.ts | 2 +- .../auth/src/token/auth-cookie.service.ts | 62 +-- .../auth/src/token/refresh-token.service.ts | 10 +- .../auth/src/token/token-expiry.config.ts | 2 +- packages/auth/src/token/token.service.ts | 10 +- packages/auth/src/user/credentials.service.ts | 8 +- .../auth/src/user/token-encryption.service.ts | 2 +- packages/auth/src/user/user.mapper.ts | 2 +- packages/auth/src/user/user.service.ts | 4 +- packages/file-upload/package.json | 29 ++ .../file-upload/src/extractor-registry.ts | 54 +++ .../src/extractors/docx.extractor.ts | 28 ++ .../src/extractors/pdf.extractor.ts | 28 ++ .../src/extractors/plain-text.extractor.ts | 25 ++ .../file-upload/src/file-extraction.module.ts | 42 ++ packages/file-upload/src/index.ts | 34 ++ packages/file-upload/src/types.ts | 83 ++++ packages/file-upload/src/validators.ts | 107 +++++ packages/file-upload/tsconfig.json | 8 + .../system/src/base/named-entity.service.ts | 2 +- .../system/src/base/pagination.service.ts | 2 +- packages/system/src/base/pagination.types.ts | 8 +- .../system/src/database/prisma.service.ts | 2 +- .../system/src/mail/resend/resend.module.ts | 11 +- .../template/handlebars-template.service.ts | 2 +- packages/ui/src/components/Calendar.tsx | 3 + .../ui/src/components/SearchableSelect.tsx | 17 +- packages/ui/src/index.ts | 1 + 90 files changed, 5020 insertions(+), 154 deletions(-) create mode 100644 apps/server/src/modules/app/health.controller.ts create mode 100644 apps/server/src/modules/cv-parser/__tests__/cv-parser.service.spec.ts create mode 100644 apps/server/src/modules/cv-parser/__tests__/entity-resolver.service.spec.ts create mode 100644 apps/server/src/modules/cv-parser/__tests__/fixtures/mock-ai-responses.ts create mode 100644 apps/server/src/modules/cv-parser/__tests__/fixtures/sample-cv.md create mode 100644 apps/server/src/modules/cv-parser/__tests__/fixtures/sample-cv.txt create mode 100644 apps/server/src/modules/cv-parser/cv-parser.module.ts create mode 100644 apps/server/src/modules/cv-parser/cv-parser.resolver.ts create mode 100644 apps/server/src/modules/cv-parser/cv-parser.service.ts create mode 100644 apps/server/src/modules/cv-parser/entity-resolver.service.ts create mode 100644 apps/server/src/modules/cv-parser/file-upload.controller.ts create mode 100644 apps/server/src/modules/cv-parser/persistence.service.ts create mode 100644 apps/server/src/modules/cv-parser/types/draft.types.ts create mode 100644 apps/server/src/modules/cv-parser/types/input.types.ts create mode 100644 apps/server/test/cv-upload-integration.e2e-spec.ts create mode 100644 apps/server/test/cv-upload.e2e-spec.ts create mode 100644 apps/server/test/cv-upload.integration.ts create mode 100644 apps/server/vitest.config.ts create mode 100644 packages/ai-parser/jest.config.ts create mode 100644 packages/ai-parser/package.json create mode 100644 packages/ai-parser/src/__tests__/ai-parser.service.spec.ts create mode 100644 packages/ai-parser/src/ai-parser.service.test.ts create mode 100644 packages/ai-parser/src/ai-parser.service.ts create mode 100644 packages/ai-parser/src/cv-parser.module.ts create mode 100644 packages/ai-parser/src/index.ts create mode 100644 packages/ai-parser/src/prompts.ts create mode 100644 packages/ai-parser/src/schemas.ts create mode 100644 packages/ai-parser/tsconfig.json create mode 100644 packages/ai-provider/TEST_SETUP.md create mode 100644 packages/ai-provider/jest.config.ts create mode 100644 packages/ai-provider/package.json create mode 100644 packages/ai-provider/src/ai.module.ts create mode 100644 packages/ai-provider/src/index.ts create mode 100644 packages/ai-provider/src/providers/index.ts create mode 100644 packages/ai-provider/src/providers/llama-cpp.provider.integration.test.ts create mode 100644 packages/ai-provider/src/providers/llama-cpp.provider.ts create mode 100644 packages/ai-provider/src/types.ts create mode 100644 packages/ai-provider/tsconfig.json create mode 100644 packages/file-upload/package.json create mode 100644 packages/file-upload/src/extractor-registry.ts create mode 100644 packages/file-upload/src/extractors/docx.extractor.ts create mode 100644 packages/file-upload/src/extractors/pdf.extractor.ts create mode 100644 packages/file-upload/src/extractors/plain-text.extractor.ts create mode 100644 packages/file-upload/src/file-extraction.module.ts create mode 100644 packages/file-upload/src/index.ts create mode 100644 packages/file-upload/src/types.ts create mode 100644 packages/file-upload/src/validators.ts create mode 100644 packages/file-upload/tsconfig.json diff --git a/apps/server/src/config/env.validation.ts b/apps/server/src/config/env.validation.ts index b07190a..eb00f60 100644 --- a/apps/server/src/config/env.validation.ts +++ b/apps/server/src/config/env.validation.ts @@ -10,10 +10,12 @@ const jwtExpirySchema = Joi.string() export const envValidationSchema = Joi.object({ // Database Configuration - POSTGRES_USER: Joi.string().required(), - POSTGRES_PASSWORD: Joi.string().required(), - POSTGRES_DB: Joi.string().required(), - DATABASE_URL: Joi.string().uri().required(), + POSTGRES_USER: Joi.string().default("cv"), + POSTGRES_PASSWORD: Joi.string().default("cv"), + POSTGRES_DB: Joi.string().default("cv"), + DATABASE_URL: Joi.string() + .uri() + .default("postgresql://cv:cv@localhost:5432/cv"), // Server Configuration PORT: Joi.number().default(3000), @@ -23,10 +25,13 @@ export const envValidationSchema = Joi.object({ .default("development"), // JWT Configuration - JWT_SECRET: Joi.string().min(16).required().messages({ - "string.min": "JWT_SECRET must be at least 16 characters long for security", - "any.required": "JWT_SECRET is required", - }), + JWT_SECRET: Joi.string() + .min(16) + .default("dev-jwt-secret-change-in-production") + .messages({ + "string.min": + "JWT_SECRET must be at least 16 characters long for security", + }), JWT_ACCESS_TOKEN_EXPIRY: jwtExpirySchema.default("15m"), JWT_REFRESH_TOKEN_EXPIRY: jwtExpirySchema.default("7d"), @@ -47,8 +52,8 @@ export const envValidationSchema = Joi.object({ // Database Port DB_PORT: Joi.number().default(5432), - // Resend Configuration - RESEND_API_KEY: Joi.string().required(), + // Resend Configuration (optional in dev - emails will be logged to console) + RESEND_API_KEY: Joi.string().allow("").default(""), // Email Configuration EMAIL_FROM_ADDRESS: Joi.string().email().optional(), @@ -58,9 +63,11 @@ export const envValidationSchema = Joi.object({ PASSWORD_RESET_TOKEN_EXPIRY: jwtExpirySchema.default("1h"), // Encryption Configuration - ENCRYPTION_KEY: Joi.string().min(32).required().messages({ - "string.min": - "ENCRYPTION_KEY must be at least 32 characters long for security", - "any.required": "ENCRYPTION_KEY is required for token encryption", - }), + ENCRYPTION_KEY: Joi.string() + .min(32) + .default("dev-encryption-key-32-chars-long!") + .messages({ + "string.min": + "ENCRYPTION_KEY must be at least 32 characters long for security", + }), }); diff --git a/apps/server/src/modules/app.module.ts b/apps/server/src/modules/app.module.ts index b948c95..1e823c7 100644 --- a/apps/server/src/modules/app.module.ts +++ b/apps/server/src/modules/app.module.ts @@ -17,6 +17,7 @@ import { AppModule as AppModuleComponent } from "./app/app.module"; import { ApplicationModule } from "./application/application.module"; import { AuthenticationModule } from "./authentication/authentication.module"; import { CurrentUserModule } from "./current-user/current-user.module"; +import { CVParserModule } from "./cv-parser/cv-parser.module"; import { CVTemplateModule } from "./cv-template/cv-template.module"; import { SeedModule } from "./database/seed/seed.module"; import { EducationModule } from "./education/education.module"; @@ -78,6 +79,7 @@ import { VacancyModule } from "./vacancies/vacancy.module"; OrganizationModule, VacancyModule, ApplicationModule, + CVParserModule, CVTemplateModule, EducationModule, SeedModule, diff --git a/apps/server/src/modules/app/app.module.ts b/apps/server/src/modules/app/app.module.ts index 4a2ccab..a851b79 100644 --- a/apps/server/src/modules/app/app.module.ts +++ b/apps/server/src/modules/app/app.module.ts @@ -1,8 +1,10 @@ import { Module } from "@nestjs/common"; import { AppResolver } from "./app.resolver"; import { AppService } from "./app.service"; +import { HealthController } from "./health.controller"; @Module({ + controllers: [HealthController], providers: [AppService, AppResolver], exports: [AppService], }) diff --git a/apps/server/src/modules/app/health.controller.ts b/apps/server/src/modules/app/health.controller.ts new file mode 100644 index 0000000..d29dbcd --- /dev/null +++ b/apps/server/src/modules/app/health.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get } from "@nestjs/common"; +import { AppService } from "./app.service"; + +@Controller("health") +export class HealthController { + constructor(private readonly appService: AppService) {} + + @Get() + check() { + return this.appService.getHealth(); + } +} diff --git a/apps/server/src/modules/cv-parser/__tests__/cv-parser.service.spec.ts b/apps/server/src/modules/cv-parser/__tests__/cv-parser.service.spec.ts new file mode 100644 index 0000000..1d106d8 --- /dev/null +++ b/apps/server/src/modules/cv-parser/__tests__/cv-parser.service.spec.ts @@ -0,0 +1,333 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + CV_PARSER_SERVICE, + type CVParserService as CVParser, +} from "@cv/ai-parser"; +import { TEXT_EXTRACTOR_REGISTRY } from "@cv/file-upload"; +import { Test, type TestingModule } from "@nestjs/testing"; +import { CVParserService } from "../cv-parser.service"; +import { EntityResolverService } from "../entity-resolver.service"; +import { + mockJaneDoeParsedCV, + mockJohnSmithParsedCV, +} from "./fixtures/mock-ai-responses"; + +describe("CVParserService", () => { + let service: CVParserService; + let mockCVParser: jest.Mocked; + let mockTextExtractorRegistry: jest.Mocked<{ + extract: ( + buffer: Buffer, + mimeType: string, + ) => Promise<{ + success: boolean; + text?: string; + error?: string; + }>; + }>; + let mockEntityResolver: jest.Mocked; + + const fixturesPath = join(__dirname, "fixtures"); + const sampleTxtCV = readFileSync( + join(fixturesPath, "sample-cv.txt"), + "utf-8", + ); + const sampleMdCV = readFileSync(join(fixturesPath, "sample-cv.md"), "utf-8"); + + beforeEach(async () => { + mockCVParser = { + parseCVText: jest.fn(), + } as unknown as jest.Mocked; + + mockTextExtractorRegistry = { + extract: jest.fn(), + }; + + mockEntityResolver = { + resolveCompany: jest.fn(), + resolveRole: jest.fn(), + resolveLevel: jest.fn(), + resolveSkill: jest.fn(), + resolveSkills: jest.fn(), + resolveInstitution: jest.fn(), + } as unknown as jest.Mocked; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CVParserService, + { + provide: CV_PARSER_SERVICE, + useValue: mockCVParser, + }, + { + provide: TEXT_EXTRACTOR_REGISTRY, + useValue: mockTextExtractorRegistry, + }, + { + provide: EntityResolverService, + useValue: mockEntityResolver, + }, + ], + }).compile(); + + service = module.get(CVParserService); + }); + + describe("parseStory", () => { + it("parses a career narrative from plain text", async () => { + mockCVParser.parseCVText.mockResolvedValue(mockJohnSmithParsedCV); + + const result = await service.parseStory(sampleTxtCV); + + expect(mockCVParser.parseCVText).toHaveBeenCalledWith(sampleTxtCV); + expect(result).toEqual(mockJohnSmithParsedCV); + expect(result.personalInfo?.name).toBe("John Smith"); + expect(result.jobExperiences).toHaveLength(3); + expect(result.education).toHaveLength(1); + }); + + it("parses a career narrative from markdown", async () => { + mockCVParser.parseCVText.mockResolvedValue(mockJaneDoeParsedCV); + + const result = await service.parseStory(sampleMdCV); + + expect(mockCVParser.parseCVText).toHaveBeenCalledWith(sampleMdCV); + expect(result).toEqual(mockJaneDoeParsedCV); + expect(result.personalInfo?.name).toBe("Jane Doe"); + expect(result.jobExperiences).toHaveLength(3); + expect(result.education).toHaveLength(2); + }); + + it("throws error when story text is empty", async () => { + await expect(service.parseStory("")).rejects.toThrow( + "Story text cannot be empty", + ); + + expect(mockCVParser.parseCVText).not.toHaveBeenCalled(); + }); + + it("throws error when story text is only whitespace", async () => { + await expect(service.parseStory(" \n\t ")).rejects.toThrow( + "Story text cannot be empty", + ); + + expect(mockCVParser.parseCVText).not.toHaveBeenCalled(); + }); + + it("throws error when story text exceeds maximum length", async () => { + const longText = "a".repeat(50001); + + await expect(service.parseStory(longText)).rejects.toThrow( + "Story text is too long (max 50,000 characters)", + ); + + expect(mockCVParser.parseCVText).not.toHaveBeenCalled(); + }); + + it("accepts story text at maximum length boundary", async () => { + const maxLengthText = "a".repeat(50000); + mockCVParser.parseCVText.mockResolvedValue({ + jobExperiences: [], + education: [], + skills: [], + }); + + await service.parseStory(maxLengthText); + + expect(mockCVParser.parseCVText).toHaveBeenCalledWith(maxLengthText); + }); + + it("extracts job experiences with correct structure", async () => { + mockCVParser.parseCVText.mockResolvedValue(mockJohnSmithParsedCV); + + const result = await service.parseStory(sampleTxtCV); + + const firstJob = result.jobExperiences[0]; + expect(firstJob.companyName).toBe("Acme Corporation"); + expect(firstJob.roleName).toBe("Senior Software Engineer"); + expect(firstJob.levelName).toBe("Senior"); + expect(firstJob.startDate).toBe("2020-01-01"); + expect(firstJob.endDate).toBeNull(); + expect(firstJob.skills).toContain("TypeScript"); + expect(firstJob.skills).toContain("Kubernetes"); + }); + + it("extracts education with correct structure", async () => { + mockCVParser.parseCVText.mockResolvedValue(mockJohnSmithParsedCV); + + const result = await service.parseStory(sampleTxtCV); + + const education = result.education[0]; + expect(education.institutionName).toBe("Stanford University"); + expect(education.degree).toBe("Bachelor of Science"); + expect(education.fieldOfStudy).toBe("Computer Science"); + expect(education.startDate).toBe("2010-09-01"); + expect(education.endDate).toBe("2014-06-30"); + }); + + it("extracts overall skills list", async () => { + mockCVParser.parseCVText.mockResolvedValue(mockJohnSmithParsedCV); + + const result = await service.parseStory(sampleTxtCV); + + expect(result.skills).toContain("TypeScript"); + expect(result.skills).toContain("Docker"); + expect(result.skills).toContain("AWS"); + expect(result.skills.length).toBeGreaterThan(0); + }); + }); + + describe("parseFile", () => { + const mockBuffer = Buffer.from("mock file content"); + const mockMimeType = "text/plain"; + const mockFileName = "resume.txt"; + + it("parses a valid text file", async () => { + mockTextExtractorRegistry.extract.mockResolvedValue({ + success: true, + text: sampleTxtCV, + }); + mockCVParser.parseCVText.mockResolvedValue(mockJohnSmithParsedCV); + + const result = await service.parseFile( + mockBuffer, + mockMimeType, + mockFileName, + ); + + expect(mockTextExtractorRegistry.extract).toHaveBeenCalledWith( + mockBuffer, + mockMimeType, + ); + expect(mockCVParser.parseCVText).toHaveBeenCalledWith(sampleTxtCV); + expect(result).toEqual(mockJohnSmithParsedCV); + }); + + it("throws error when text extraction fails", async () => { + mockTextExtractorRegistry.extract.mockResolvedValue({ + success: false, + error: "Unsupported file format", + }); + + await expect( + service.parseFile(mockBuffer, mockMimeType, mockFileName), + ).rejects.toThrow("Text extraction failed: Unsupported file format"); + }); + + it("throws error when extracted text is empty", async () => { + mockTextExtractorRegistry.extract.mockResolvedValue({ + success: true, + text: "", + }); + + await expect( + service.parseFile(mockBuffer, mockMimeType, mockFileName), + ).rejects.toThrow("Could not extract any text from the file"); + }); + + it("throws error when extracted text is only whitespace", async () => { + mockTextExtractorRegistry.extract.mockResolvedValue({ + success: true, + text: " \n\t ", + }); + + await expect( + service.parseFile(mockBuffer, mockMimeType, mockFileName), + ).rejects.toThrow("Could not extract any text from the file"); + }); + }); + + describe("parseStoryWithResolution", () => { + beforeEach(() => { + mockCVParser.parseCVText.mockResolvedValue(mockJohnSmithParsedCV); + + mockEntityResolver.resolveCompany.mockImplementation(async (name) => ({ + id: name === "Acme Corporation" ? "company-1" : null, + name, + })); + + mockEntityResolver.resolveRole.mockImplementation(async (name) => ({ + id: name === "Senior Software Engineer" ? "role-1" : null, + name, + })); + + mockEntityResolver.resolveLevel.mockImplementation(async (name) => ({ + id: name === "Senior" ? "level-1" : null, + name: name ?? "Mid-level", + })); + + mockEntityResolver.resolveInstitution.mockImplementation( + async (name) => ({ + id: name === "Stanford University" ? "institution-1" : null, + name, + }), + ); + + mockEntityResolver.resolveSkills.mockImplementation(async (names) => + names.map((name) => ({ + id: ["TypeScript", "JavaScript"].includes(name) + ? `skill-${name}` + : null, + name, + })), + ); + }); + + it("resolves entities for parsed CV data", async () => { + const result = await service.parseStoryWithResolution(sampleTxtCV); + + expect(result.jobExperiences).toHaveLength(3); + expect(result.education).toHaveLength(1); + + const firstJob = result.jobExperiences[0]; + expect(firstJob.company.id).toBe("company-1"); + expect(firstJob.company.name).toBe("Acme Corporation"); + expect(firstJob.role.id).toBe("role-1"); + expect(firstJob.level.id).toBe("level-1"); + }); + + it("returns null IDs for unmatched entities", async () => { + const result = await service.parseStoryWithResolution(sampleTxtCV); + + const secondJob = result.jobExperiences[1]; + expect(secondJob.company.id).toBeNull(); + expect(secondJob.company.name).toBe("TechStart Inc"); + }); + + it("converts date strings to Date objects", async () => { + const result = await service.parseStoryWithResolution(sampleTxtCV); + + const firstJob = result.jobExperiences[0]; + expect(firstJob.startDate).toBeInstanceOf(Date); + expect(firstJob.endDate).toBeNull(); + + const lastJob = result.jobExperiences[2]; + expect(lastJob.endDate).toBeInstanceOf(Date); + }); + + it("resolves education institutions", async () => { + const result = await service.parseStoryWithResolution(sampleTxtCV); + + const education = result.education[0]; + expect(education.institution.id).toBe("institution-1"); + expect(education.institution.name).toBe("Stanford University"); + expect(education.degree).toBe("Bachelor of Science"); + }); + + it("resolves skills for both jobs and education", async () => { + const result = await service.parseStoryWithResolution(sampleTxtCV); + + const firstJob = result.jobExperiences[0]; + const typescriptSkill = firstJob.skills.find( + (s) => s.name === "TypeScript", + ); + expect(typescriptSkill?.id).toBe("skill-TypeScript"); + + const kubernetesSkill = firstJob.skills.find( + (s) => s.name === "Kubernetes", + ); + expect(kubernetesSkill?.id).toBeNull(); + }); + }); +}); diff --git a/apps/server/src/modules/cv-parser/__tests__/entity-resolver.service.spec.ts b/apps/server/src/modules/cv-parser/__tests__/entity-resolver.service.spec.ts new file mode 100644 index 0000000..a9a9704 --- /dev/null +++ b/apps/server/src/modules/cv-parser/__tests__/entity-resolver.service.spec.ts @@ -0,0 +1,345 @@ +import { PrismaService } from "@cv/system"; +import { Test, type TestingModule } from "@nestjs/testing"; +import { EntityResolverService } from "../entity-resolver.service"; + +describe("EntityResolverService", () => { + let service: EntityResolverService; + let mockPrisma: jest.Mocked; + + beforeEach(async () => { + mockPrisma = { + company: { + findFirst: jest.fn(), + }, + role: { + findFirst: jest.fn(), + }, + level: { + findFirst: jest.fn(), + }, + skill: { + findFirst: jest.fn(), + }, + institution: { + findFirst: jest.fn(), + }, + } as unknown as jest.Mocked; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EntityResolverService, + { + provide: PrismaService, + useValue: mockPrisma, + }, + ], + }).compile(); + + service = module.get(EntityResolverService); + }); + + describe("resolveCompany", () => { + it("returns matched company with ID when found", async () => { + const mockCompany = { id: "company-123", name: "Acme Corporation" }; + (mockPrisma.company.findFirst as jest.Mock).mockResolvedValue( + mockCompany, + ); + + const result = await service.resolveCompany("Acme Corporation"); + + expect(mockPrisma.company.findFirst).toHaveBeenCalledWith({ + where: { name: { equals: "Acme Corporation", mode: "insensitive" } }, + }); + expect(result).toEqual({ id: "company-123", name: "Acme Corporation" }); + }); + + it("returns draft entity with null ID when not found", async () => { + (mockPrisma.company.findFirst as jest.Mock).mockResolvedValue(null); + + const result = await service.resolveCompany("Unknown Company"); + + expect(result).toEqual({ id: null, name: "Unknown Company" }); + }); + + it("performs case-insensitive matching", async () => { + const mockCompany = { id: "company-123", name: "ACME Corporation" }; + (mockPrisma.company.findFirst as jest.Mock).mockResolvedValue( + mockCompany, + ); + + const result = await service.resolveCompany("acme corporation"); + + expect(mockPrisma.company.findFirst).toHaveBeenCalledWith({ + where: { name: { equals: "acme corporation", mode: "insensitive" } }, + }); + expect(result.id).toBe("company-123"); + expect(result.name).toBe("ACME Corporation"); + }); + }); + + describe("resolveRole", () => { + it("returns matched role with ID when found", async () => { + const mockRole = { id: "role-456", name: "Senior Software Engineer" }; + (mockPrisma.role.findFirst as jest.Mock).mockResolvedValue(mockRole); + + const result = await service.resolveRole("Senior Software Engineer"); + + expect(mockPrisma.role.findFirst).toHaveBeenCalledWith({ + where: { + name: { equals: "Senior Software Engineer", mode: "insensitive" }, + }, + }); + expect(result).toEqual({ + id: "role-456", + name: "Senior Software Engineer", + }); + }); + + it("returns draft entity with null ID when not found", async () => { + (mockPrisma.role.findFirst as jest.Mock).mockResolvedValue(null); + + const result = await service.resolveRole("Chief Happiness Officer"); + + expect(result).toEqual({ id: null, name: "Chief Happiness Officer" }); + }); + }); + + describe("resolveLevel", () => { + it("returns matched level with ID when found", async () => { + const mockLevel = { id: "level-789", name: "Senior" }; + (mockPrisma.level.findFirst as jest.Mock).mockResolvedValue(mockLevel); + + const result = await service.resolveLevel("Senior"); + + expect(mockPrisma.level.findFirst).toHaveBeenCalledWith({ + where: { name: { equals: "Senior", mode: "insensitive" } }, + }); + expect(result).toEqual({ id: "level-789", name: "Senior" }); + }); + + it("defaults to Mid-level when no name provided", async () => { + const mockLevel = { id: "level-mid", name: "Mid-level" }; + (mockPrisma.level.findFirst as jest.Mock).mockResolvedValue(mockLevel); + + const result = await service.resolveLevel(undefined); + + expect(mockPrisma.level.findFirst).toHaveBeenCalledWith({ + where: { name: { equals: "Mid-level", mode: "insensitive" } }, + }); + expect(result).toEqual({ id: "level-mid", name: "Mid-level" }); + }); + + it("defaults to Mid-level when empty string provided", async () => { + (mockPrisma.level.findFirst as jest.Mock).mockResolvedValue(null); + + const result = await service.resolveLevel(""); + + expect(mockPrisma.level.findFirst).toHaveBeenCalledWith({ + where: { name: { equals: "Mid-level", mode: "insensitive" } }, + }); + expect(result).toEqual({ id: null, name: "Mid-level" }); + }); + + it("returns draft entity with null ID when level not found", async () => { + (mockPrisma.level.findFirst as jest.Mock).mockResolvedValue(null); + + const result = await service.resolveLevel("Principal"); + + expect(result).toEqual({ id: null, name: "Principal" }); + }); + }); + + describe("resolveSkill", () => { + it("returns matched skill with ID when found", async () => { + const mockSkill = { id: "skill-ts", name: "TypeScript" }; + (mockPrisma.skill.findFirst as jest.Mock).mockResolvedValue(mockSkill); + + const result = await service.resolveSkill("TypeScript"); + + expect(mockPrisma.skill.findFirst).toHaveBeenCalledWith({ + where: { name: { equals: "TypeScript", mode: "insensitive" } }, + }); + expect(result).toEqual({ id: "skill-ts", name: "TypeScript" }); + }); + + it("returns draft entity with null ID when not found", async () => { + (mockPrisma.skill.findFirst as jest.Mock).mockResolvedValue(null); + + const result = await service.resolveSkill("ObscureFramework"); + + expect(result).toEqual({ id: null, name: "ObscureFramework" }); + }); + + it("trims whitespace from skill name", async () => { + const mockSkill = { id: "skill-ts", name: "TypeScript" }; + (mockPrisma.skill.findFirst as jest.Mock).mockResolvedValue(mockSkill); + + const result = await service.resolveSkill(" TypeScript "); + + expect(mockPrisma.skill.findFirst).toHaveBeenCalledWith({ + where: { name: { equals: "TypeScript", mode: "insensitive" } }, + }); + expect(result).toEqual({ id: "skill-ts", name: "TypeScript" }); + }); + + it("returns empty draft entity for empty/whitespace-only input", async () => { + const result = await service.resolveSkill(" "); + + expect(mockPrisma.skill.findFirst).not.toHaveBeenCalled(); + expect(result).toEqual({ id: null, name: "" }); + }); + }); + + describe("resolveInstitution", () => { + it("returns matched institution with ID when found", async () => { + const mockInstitution = { + id: "inst-stanford", + name: "Stanford University", + }; + (mockPrisma.institution.findFirst as jest.Mock).mockResolvedValue( + mockInstitution, + ); + + const result = await service.resolveInstitution("Stanford University"); + + expect(mockPrisma.institution.findFirst).toHaveBeenCalledWith({ + where: { name: { equals: "Stanford University", mode: "insensitive" } }, + }); + expect(result).toEqual({ + id: "inst-stanford", + name: "Stanford University", + }); + }); + + it("returns draft entity with null ID when not found", async () => { + (mockPrisma.institution.findFirst as jest.Mock).mockResolvedValue(null); + + const result = await service.resolveInstitution("Unknown University"); + + expect(result).toEqual({ id: null, name: "Unknown University" }); + }); + }); + + describe("resolveSkills", () => { + it("resolves multiple skills in parallel", async () => { + (mockPrisma.skill.findFirst as jest.Mock) + .mockResolvedValueOnce({ id: "skill-ts", name: "TypeScript" }) + .mockResolvedValueOnce({ id: "skill-js", name: "JavaScript" }) + .mockResolvedValueOnce(null); + + const result = await service.resolveSkills([ + "TypeScript", + "JavaScript", + "Rust", + ]); + + expect(mockPrisma.skill.findFirst).toHaveBeenCalledTimes(3); + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ id: "skill-ts", name: "TypeScript" }); + expect(result[1]).toEqual({ id: "skill-js", name: "JavaScript" }); + expect(result[2]).toEqual({ id: null, name: "Rust" }); + }); + + it("deduplicates skill names before resolving", async () => { + (mockPrisma.skill.findFirst as jest.Mock).mockResolvedValue({ + id: "skill-ts", + name: "TypeScript", + }); + + const result = await service.resolveSkills([ + "TypeScript", + "TypeScript", + "TypeScript", + ]); + + expect(mockPrisma.skill.findFirst).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + }); + + it("filters out empty and whitespace-only skill names", async () => { + (mockPrisma.skill.findFirst as jest.Mock).mockResolvedValue({ + id: "skill-ts", + name: "TypeScript", + }); + + const result = await service.resolveSkills([ + "TypeScript", + "", + " ", + null as unknown as string, + undefined as unknown as string, + ]); + + expect(mockPrisma.skill.findFirst).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ id: "skill-ts", name: "TypeScript" }); + }); + + it("returns empty array for empty input", async () => { + const result = await service.resolveSkills([]); + + expect(mockPrisma.skill.findFirst).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it("handles mixed matching and non-matching skills", async () => { + (mockPrisma.skill.findFirst as jest.Mock) + .mockResolvedValueOnce({ id: "skill-1", name: "React" }) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: "skill-2", name: "Node.js" }) + .mockResolvedValueOnce(null); + + const result = await service.resolveSkills([ + "React", + "SomeObscureLib", + "Node.js", + "AnotherRareSkill", + ]); + + expect(result).toHaveLength(4); + expect(result[0].id).toBe("skill-1"); + expect(result[1].id).toBeNull(); + expect(result[2].id).toBe("skill-2"); + expect(result[3].id).toBeNull(); + }); + }); + + describe("fuzzy matching scenarios", () => { + it("matches company names case-insensitively", async () => { + const mockCompany = { id: "c-1", name: "Google LLC" }; + (mockPrisma.company.findFirst as jest.Mock).mockResolvedValue( + mockCompany, + ); + + const variations = [ + "google llc", + "GOOGLE LLC", + "Google LLC", + "GoOgLe LlC", + ]; + + for (const variation of variations) { + const result = await service.resolveCompany(variation); + expect(result.id).toBe("c-1"); + } + }); + + it("handles abbreviated vs full company names", async () => { + (mockPrisma.company.findFirst as jest.Mock).mockResolvedValue(null); + + const result = await service.resolveCompany("IBM"); + + expect(result.id).toBeNull(); + expect(result.name).toBe("IBM"); + }); + + it("preserves original name from database when matched", async () => { + const mockSkill = { id: "skill-1", name: "Node.js" }; + (mockPrisma.skill.findFirst as jest.Mock).mockResolvedValue(mockSkill); + + const result = await service.resolveSkill("nodejs"); + + expect(result.name).toBe("Node.js"); + }); + }); +}); diff --git a/apps/server/src/modules/cv-parser/__tests__/fixtures/mock-ai-responses.ts b/apps/server/src/modules/cv-parser/__tests__/fixtures/mock-ai-responses.ts new file mode 100644 index 0000000..b715d4d --- /dev/null +++ b/apps/server/src/modules/cv-parser/__tests__/fixtures/mock-ai-responses.ts @@ -0,0 +1,172 @@ +import type { ParsedCVData } from "@cv/ai-parser"; + +/** + * Mock AI response for the plain text CV (John Smith) + */ +export const mockJohnSmithParsedCV: ParsedCVData = { + personalInfo: { + name: "John Smith", + introduction: + "Experienced software engineer with 8+ years of experience building scalable web applications and distributed systems.", + }, + jobExperiences: [ + { + companyName: "Acme Corporation", + roleName: "Senior Software Engineer", + levelName: "Senior", + startDate: "2020-01-01", + endDate: null, + description: + "Led development of microservices architecture serving 10M+ users. Mentored team of 5 junior developers.", + skills: ["TypeScript", "Node.js", "Kubernetes", "PostgreSQL", "Redis"], + }, + { + companyName: "TechStart Inc", + roleName: "Software Engineer", + levelName: "Mid-level", + startDate: "2016-03-01", + endDate: "2019-12-31", + description: + "Built REST APIs and GraphQL endpoints for e-commerce platform. Optimized database queries.", + skills: ["JavaScript", "Python", "MySQL", "Docker", "AWS"], + }, + { + companyName: "WebDev Agency", + roleName: "Junior Developer", + levelName: "Junior", + startDate: "2014-06-01", + endDate: "2016-02-28", + description: "Developed responsive websites for various clients.", + skills: ["PHP", "JavaScript", "HTML", "CSS"], + }, + ], + education: [ + { + institutionName: "Stanford University", + degree: "Bachelor of Science", + fieldOfStudy: "Computer Science", + startDate: "2010-09-01", + endDate: "2014-06-30", + description: "GPA: 3.8/4.0, Dean's List", + skills: ["Algorithms", "Data Structures", "C++"], + }, + ], + skills: [ + "TypeScript", + "JavaScript", + "Python", + "Node.js", + "React", + "GraphQL", + "PostgreSQL", + "MongoDB", + "Redis", + "Docker", + "Kubernetes", + "AWS", + "Git", + ], +}; + +/** + * Mock AI response for the markdown CV (Jane Doe) + */ +export const mockJaneDoeParsedCV: ParsedCVData = { + personalInfo: { + name: "Jane Doe", + introduction: + "Passionate full-stack developer with 6 years of experience in building modern web applications.", + }, + jobExperiences: [ + { + companyName: "Global Tech Solutions", + roleName: "Lead Developer", + levelName: "Lead", + startDate: "2021-04-01", + endDate: null, + description: + "Architected and led development of customer portal used by 50K+ enterprises.", + skills: [ + "React", + "TypeScript", + "NestJS", + "PostgreSQL", + "RabbitMQ", + "AWS", + ], + }, + { + companyName: "StartupXYZ", + roleName: "Software Engineer", + levelName: "Mid-level", + startDate: "2018-08-01", + endDate: "2021-03-31", + description: "Built real-time collaboration features using WebSockets.", + skills: ["React", "React Native", "Node.js", "MongoDB", "Firebase"], + }, + { + companyName: "Digital Creations Ltd", + roleName: "Junior Software Developer", + levelName: "Junior", + startDate: "2017-01-01", + endDate: "2018-07-31", + description: "Created RESTful APIs for content management system.", + skills: ["Express.js", "JavaScript", "MySQL", "Jest"], + }, + ], + education: [ + { + institutionName: "Columbia University", + degree: "Master of Science", + fieldOfStudy: "Software Engineering", + startDate: "2015-09-01", + endDate: "2017-05-31", + description: "Focus: Distributed Systems and Cloud Computing", + skills: [], + }, + { + institutionName: "NYU", + degree: "Bachelor of Arts", + fieldOfStudy: "Computer Science", + startDate: "2011-09-01", + endDate: "2015-05-31", + description: "Minor: Mathematics, Graduated Magna Cum Laude", + skills: [], + }, + ], + skills: [ + "TypeScript", + "JavaScript", + "Python", + "Go", + "React", + "React Native", + "Next.js", + "Vue.js", + "Node.js", + "NestJS", + "Express", + "GraphQL", + "PostgreSQL", + "MongoDB", + "Redis", + "AWS", + "Docker", + "Kubernetes", + "Terraform", + ], +}; + +/** + * Mock AI response wrapped in markdown code block (common LLM output format) + */ +export const createMockAIJsonResponse = (data: ParsedCVData): string => + `\`\`\`json +${JSON.stringify(data, null, 2)} +\`\`\``; + +/** + * Mock AI response as raw JSON (alternative LLM output format) + */ +export const createMockAIRawJsonResponse = (data: ParsedCVData): string => + JSON.stringify(data); diff --git a/apps/server/src/modules/cv-parser/__tests__/fixtures/sample-cv.md b/apps/server/src/modules/cv-parser/__tests__/fixtures/sample-cv.md new file mode 100644 index 0000000..1dc52d2 --- /dev/null +++ b/apps/server/src/modules/cv-parser/__tests__/fixtures/sample-cv.md @@ -0,0 +1,62 @@ +# Jane Doe +**Full Stack Developer** | New York, NY | jane.doe@email.com + +--- + +## Summary +Passionate full-stack developer with 6 years of experience in building modern web applications. Strong background in React, Node.js, and cloud technologies. Committed to writing maintainable, well-tested code. + +--- + +## Experience + +### Lead Developer +**Global Tech Solutions** | April 2021 - Present + +- Architected and led development of customer portal used by 50K+ enterprises +- Established coding standards and code review processes for team of 8 engineers +- Migrated legacy monolith to event-driven microservices architecture + +**Technologies:** React, TypeScript, NestJS, PostgreSQL, RabbitMQ, AWS + +### Software Engineer +**StartupXYZ** | August 2018 - March 2021 + +- Built real-time collaboration features using WebSockets +- Developed mobile-responsive UI components with React Native +- Integrated third-party payment and authentication services + +**Technologies:** React, React Native, Node.js, MongoDB, Firebase + +### Junior Software Developer +**Digital Creations Ltd** | January 2017 - July 2018 + +- Created RESTful APIs for content management system +- Wrote unit and integration tests achieving 85% code coverage +- Participated in agile ceremonies and sprint planning + +**Technologies:** Express.js, JavaScript, MySQL, Jest + +--- + +## Education + +### Master of Science in Software Engineering +**Columbia University** | September 2015 - May 2017 +- Focus: Distributed Systems and Cloud Computing +- Thesis: "Scalable Event-Driven Architectures for Real-Time Applications" + +### Bachelor of Arts in Computer Science +**NYU** | September 2011 - May 2015 +- Minor: Mathematics +- Graduated Magna Cum Laude + +--- + +## Skills + +**Languages:** TypeScript, JavaScript, Python, Go +**Frontend:** React, React Native, Next.js, Vue.js +**Backend:** Node.js, NestJS, Express, GraphQL +**Databases:** PostgreSQL, MongoDB, Redis +**Cloud & DevOps:** AWS, Docker, Kubernetes, Terraform, CI/CD diff --git a/apps/server/src/modules/cv-parser/__tests__/fixtures/sample-cv.txt b/apps/server/src/modules/cv-parser/__tests__/fixtures/sample-cv.txt new file mode 100644 index 0000000..646fbda --- /dev/null +++ b/apps/server/src/modules/cv-parser/__tests__/fixtures/sample-cv.txt @@ -0,0 +1,42 @@ +John Smith +Software Engineer | San Francisco, CA + +PROFESSIONAL SUMMARY +Experienced software engineer with 8+ years of experience building scalable web applications and distributed systems. Passionate about clean code, testing, and mentoring junior developers. + +WORK EXPERIENCE + +Senior Software Engineer +Acme Corporation +January 2020 - Present +- Led development of microservices architecture serving 10M+ users +- Mentored team of 5 junior developers +- Implemented CI/CD pipelines reducing deployment time by 60% +Skills: TypeScript, Node.js, Kubernetes, PostgreSQL, Redis + +Software Engineer +TechStart Inc +March 2016 - December 2019 +- Built REST APIs and GraphQL endpoints for e-commerce platform +- Optimized database queries improving response times by 40% +- Collaborated with product team on feature specifications +Skills: JavaScript, Python, MySQL, Docker, AWS + +Junior Developer +WebDev Agency +June 2014 - February 2016 +- Developed responsive websites for various clients +- Maintained legacy PHP applications +Skills: PHP, JavaScript, HTML, CSS + +EDUCATION + +Bachelor of Science in Computer Science +Stanford University +September 2010 - June 2014 +- GPA: 3.8/4.0 +- Dean's List +Skills: Algorithms, Data Structures, C++ + +SKILLS +TypeScript, JavaScript, Python, Node.js, React, GraphQL, PostgreSQL, MongoDB, Redis, Docker, Kubernetes, AWS, Git diff --git a/apps/server/src/modules/cv-parser/cv-parser.module.ts b/apps/server/src/modules/cv-parser/cv-parser.module.ts new file mode 100644 index 0000000..70fd571 --- /dev/null +++ b/apps/server/src/modules/cv-parser/cv-parser.module.ts @@ -0,0 +1,67 @@ +import { randomBytes } from "node:crypto"; +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { CVParserModule as CVParserCoreModule } from "@cv/ai-parser"; +import { FileExtractionModule } from "@cv/file-upload"; +import { DatabaseModule } from "@cv/system"; +import { Module } from "@nestjs/common"; +import { MulterModule } from "@nestjs/platform-express"; +import { diskStorage } from "multer"; +import { CVParserResolver } from "./cv-parser.resolver"; +import { CVParserService } from "./cv-parser.service"; +import { EntityResolverService } from "./entity-resolver.service"; +import { FileUploadController } from "./file-upload.controller"; +import { CVParserPersistenceService } from "./persistence.service"; + +@Module({ + imports: [ + CVParserCoreModule.forRoot({ type: "llama-cpp" }), + FileExtractionModule.forRoot(), + DatabaseModule, + MulterModule.register({ + storage: diskStorage({ + destination: (_req, _file, cb) => { + const tmpDir = join(process.cwd(), "tmp", "cv-uploads"); + mkdirSync(tmpDir, { recursive: true }); + cb(null, tmpDir); + }, + filename: (_req, file, cb) => { + const uniqueSuffix = `${Date.now()}-${randomBytes(8).toString("hex")}`; + const ext = file.originalname.split(".").pop() ?? ""; + cb(null, `${uniqueSuffix}.${ext}`); + }, + }), + limits: { + fileSize: 10 * 1024 * 1024, // 10MB + }, + fileFilter: (_req, file, cb) => { + const allowedMimes = [ + "application/pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "text/plain", + "text/markdown", + ]; + + if (allowedMimes.includes(file.mimetype)) { + cb(null, true); + } else { + cb( + new Error( + `File type ${file.mimetype} not allowed. Supported: PDF, DOCX, TXT, MD`, + ), + false, + ); + } + }, + }), + ], + providers: [ + EntityResolverService, + CVParserService, + CVParserPersistenceService, + CVParserResolver, + ], + controllers: [FileUploadController], + exports: [CVParserService, CVParserPersistenceService], +}) +export class CVParserModule {} diff --git a/apps/server/src/modules/cv-parser/cv-parser.resolver.ts b/apps/server/src/modules/cv-parser/cv-parser.resolver.ts new file mode 100644 index 0000000..215a927 --- /dev/null +++ b/apps/server/src/modules/cv-parser/cv-parser.resolver.ts @@ -0,0 +1,54 @@ +import type { User as DomainUser } from "@cv/auth"; +import { JwtAuthGuard } from "@cv/auth"; +import { UseGuards } from "@nestjs/common"; +import { Args, Mutation, Resolver } from "@nestjs/graphql"; +import { CurrentUser } from "../current-user/current-user.decorator"; +import { CVParserService } from "./cv-parser.service"; +import { CVParserPersistenceService } from "./persistence.service"; +import { + DraftEducationType, + DraftJobExperienceType, + ParsedCVDataWithResolutionType, +} from "./types/draft.types"; +import { ParsedCVDataInput } from "./types/input.types"; + +@Resolver() +export class CVParserResolver { + constructor( + private readonly cvParserService: CVParserService, + private readonly persistenceService: CVParserPersistenceService, + ) {} + + /** + * Parse story text and resolve entities to existing database records + * Returns draft data with entity IDs where matches were found + */ + @Mutation(() => ParsedCVDataWithResolutionType) + @UseGuards(JwtAuthGuard) + async parseStory( + @CurrentUser() _user: DomainUser, + @Args("storyText") storyText: string, + ): Promise { + const resolved = + await this.cvParserService.parseStoryWithResolution(storyText); + + return new ParsedCVDataWithResolutionType( + resolved.jobExperiences.map(DraftJobExperienceType.fromResolved), + resolved.education.map(DraftEducationType.fromResolved), + ); + } + + /** + * Save approved parsed data to the database + * Creates entities that don't exist and links them to the user + */ + @Mutation(() => Boolean) + @UseGuards(JwtAuthGuard) + async approveParsedData( + @CurrentUser() user: DomainUser, + @Args("data", { type: () => ParsedCVDataInput }) data: ParsedCVDataInput, + ): Promise { + await this.persistenceService.saveParsedData(user.id, data); + return true; + } +} diff --git a/apps/server/src/modules/cv-parser/cv-parser.service.ts b/apps/server/src/modules/cv-parser/cv-parser.service.ts new file mode 100644 index 0000000..62596bb --- /dev/null +++ b/apps/server/src/modules/cv-parser/cv-parser.service.ts @@ -0,0 +1,170 @@ +import { + CV_PARSER_SERVICE, + CVParserService as CVParser, + type ParsedCVData, +} from "@cv/ai-parser"; +import { + TEXT_EXTRACTOR_REGISTRY, + type TextExtractorRegistry, + validateFile, +} from "@cv/file-upload"; +import { Inject, Injectable } from "@nestjs/common"; +import { + EntityResolverService, + type ResolvedEducation, + type ResolvedJobExperience, +} from "./entity-resolver.service"; + +/** + * Parsed CV data with resolved entities + */ +export interface ParsedCVDataWithResolution { + jobExperiences: ResolvedJobExperience[]; + education: ResolvedEducation[]; +} + +@Injectable() +export class CVParserService { + constructor( + @Inject(CV_PARSER_SERVICE) + private readonly cvParser: CVParser, + @Inject(TEXT_EXTRACTOR_REGISTRY) + private readonly textExtractorRegistry: TextExtractorRegistry, + private readonly entityResolver: EntityResolverService, + ) {} + + async parseFile( + buffer: Buffer, + mimeType: string, + originalName: string, + ): Promise { + const validation = validateFile({ + buffer, + mimeType, + originalName, + sizeBytes: buffer.length, + }); + + if (!validation.valid) { + throw new Error(`File validation failed: ${validation.error}`); + } + + const extraction = await this.textExtractorRegistry.extract( + buffer, + mimeType, + ); + + if (!extraction.success) { + throw new Error(`Text extraction failed: ${extraction.error}`); + } + + if (!extraction.text || extraction.text.trim().length === 0) { + throw new Error("Could not extract any text from the file"); + } + + return this.cvParser.parseCVText(extraction.text); + } + + async parseStory(storyText: string): Promise { + if (!storyText || storyText.trim().length === 0) { + throw new Error("Story text cannot be empty"); + } + + if (storyText.trim().length > 50000) { + throw new Error("Story text is too long (max 50,000 characters)"); + } + + return this.cvParser.parseCVText(storyText); + } + + /** + * Parse story text and resolve entities to existing database records + * Returns draft data with entity IDs where matches were found + */ + async parseStoryWithResolution( + storyText: string, + ): Promise { + const parsed = await this.parseStory(storyText); + return this.resolveEntities(parsed); + } + + /** + * Parse file and resolve entities to existing database records + */ + async parseFileWithResolution( + buffer: Buffer, + mimeType: string, + originalName: string, + ): Promise { + const parsed = await this.parseFile(buffer, mimeType, originalName); + return this.resolveEntities(parsed); + } + + /** + * Resolve entity names to existing database records + */ + private async resolveEntities( + parsed: ParsedCVData, + ): Promise { + const [jobExperiences, education] = await Promise.all([ + this.resolveJobExperiences(parsed.jobExperiences), + this.resolveEducation(parsed.education), + ]); + + return { jobExperiences, education }; + } + + /** + * Resolve job experience entities + */ + private async resolveJobExperiences( + jobs: ParsedCVData["jobExperiences"], + ): Promise { + return Promise.all( + jobs.map(async (job) => { + const [company, role, level, skills] = await Promise.all([ + this.entityResolver.resolveCompany(job.companyName), + this.entityResolver.resolveRole(job.roleName), + this.entityResolver.resolveLevel(job.levelName), + this.entityResolver.resolveSkills(job.skills ?? []), + ]); + + return { + company, + role, + level, + skills, + startDate: new Date(job.startDate), + endDate: job.endDate ? new Date(job.endDate) : null, + description: job.description ?? null, + }; + }), + ); + } + + /** + * Resolve education entities + */ + private async resolveEducation( + education: ParsedCVData["education"], + ): Promise { + return Promise.all( + education.map(async (edu) => { + const [institution, skills] = await Promise.all([ + this.entityResolver.resolveInstitution(edu.institutionName), + this.entityResolver.resolveSkills(edu.skills ?? []), + ]); + + return { + institution, + degree: edu.degree, + fieldOfStudy: edu.fieldOfStudy ?? null, + skills, + startDate: new Date(edu.startDate), + endDate: edu.endDate ? new Date(edu.endDate) : null, + description: edu.description ?? null, + }; + }), + ); + } +} diff --git a/apps/server/src/modules/cv-parser/entity-resolver.service.ts b/apps/server/src/modules/cv-parser/entity-resolver.service.ts new file mode 100644 index 0000000..d0e3d08 --- /dev/null +++ b/apps/server/src/modules/cv-parser/entity-resolver.service.ts @@ -0,0 +1,120 @@ +import { PrismaService } from "@cv/system"; +import { Injectable } from "@nestjs/common"; + +/** + * Draft entity representation - ID is null if no match found + */ +export interface DraftEntity { + id: string | null; + name: string; +} + +/** + * Resolved job experience with draft entities + */ +export interface ResolvedJobExperience { + company: DraftEntity; + role: DraftEntity; + level: DraftEntity; + skills: DraftEntity[]; + startDate: Date; + endDate: Date | null; + description: string | null; +} + +/** + * Resolved education with draft entities + */ +export interface ResolvedEducation { + institution: DraftEntity; + degree: string; + fieldOfStudy: string | null; + skills: DraftEntity[]; + startDate: Date; + endDate: Date | null; + description: string | null; +} + +/** + * Service for resolving entity names to existing database entities + * Returns stubs with null IDs for unmatched names + */ +@Injectable() +export class EntityResolverService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Resolve a company name to an existing entity + */ + async resolveCompany(name: string): Promise { + const match = await this.prisma.company.findFirst({ + where: { name: { equals: name, mode: "insensitive" } }, + }); + + return match ? { id: match.id, name: match.name } : { id: null, name }; + } + + /** + * Resolve a role name to an existing entity + */ + async resolveRole(name: string): Promise { + const match = await this.prisma.role.findFirst({ + where: { name: { equals: name, mode: "insensitive" } }, + }); + + return match ? { id: match.id, name: match.name } : { id: null, name }; + } + + /** + * Resolve a level name to an existing entity + * Falls back to "Mid-level" if not provided + */ + async resolveLevel(name?: string): Promise { + const searchName = name || "Mid-level"; + const match = await this.prisma.level.findFirst({ + where: { name: { equals: searchName, mode: "insensitive" } }, + }); + + return match + ? { id: match.id, name: match.name } + : { id: null, name: searchName }; + } + + /** + * Resolve a skill name to an existing entity + */ + async resolveSkill(name: string): Promise { + const trimmed = name.trim(); + if (!trimmed) { + return { id: null, name: "" }; + } + + const match = await this.prisma.skill.findFirst({ + where: { name: { equals: trimmed, mode: "insensitive" } }, + }); + + return match + ? { id: match.id, name: match.name } + : { id: null, name: trimmed }; + } + + /** + * Resolve an institution name to an existing entity + */ + async resolveInstitution(name: string): Promise { + const match = await this.prisma.institution.findFirst({ + where: { name: { equals: name, mode: "insensitive" } }, + }); + + return match ? { id: match.id, name: match.name } : { id: null, name }; + } + + /** + * Resolve multiple skills in parallel + */ + async resolveSkills(names: string[]): Promise { + const uniqueNames = [...new Set(names.filter((s) => s?.trim()))]; + + return Promise.all(uniqueNames.map((name) => this.resolveSkill(name))); + } +} diff --git a/apps/server/src/modules/cv-parser/file-upload.controller.ts b/apps/server/src/modules/cv-parser/file-upload.controller.ts new file mode 100644 index 0000000..efee533 --- /dev/null +++ b/apps/server/src/modules/cv-parser/file-upload.controller.ts @@ -0,0 +1,74 @@ +import { unlink } from "node:fs/promises"; +import { JwtAuthGuard } from "@cv/auth"; +import { + BadRequestException, + Controller, + InternalServerErrorException, + Logger, + Post, + UploadedFile, + UseGuards, + UseInterceptors, +} from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { CVParserService } from "./cv-parser.service"; + +@Controller("api/cv-parser") +export class FileUploadController { + private readonly logger = new Logger(FileUploadController.name); + + constructor(private cvParserService: CVParserService) {} + + @Post("upload") + @UseGuards(JwtAuthGuard) + @UseInterceptors(FileInterceptor("file")) + async uploadAndParse(@UploadedFile() file?: Express.Multer.File) { + if (!file) { + throw new BadRequestException("No file provided"); + } + + const filePath = file.path; + + try { + // Read file buffer + const buffer = + file.buffer ?? (await require("node:fs/promises").readFile(filePath)); + + // Parse the file + const parsedData = await this.cvParserService.parseFile( + buffer, + file.mimetype, + file.originalname, + ); + + return { + success: true, + data: parsedData, + fileName: file.originalname, + fileSize: file.size, + }; + } catch (error) { + this.logger.error("File parsing error:", error); + + const errorMessage = + error instanceof Error ? error.message : "Failed to process file"; + + throw new InternalServerErrorException({ + message: "File parsing failed", + details: errorMessage, + }); + } finally { + // Clean up uploaded file + if (filePath) { + try { + await unlink(filePath); + } catch (err) { + this.logger.warn( + `Failed to clean up temporary file ${filePath}:`, + err, + ); + } + } + } + } +} diff --git a/apps/server/src/modules/cv-parser/persistence.service.ts b/apps/server/src/modules/cv-parser/persistence.service.ts new file mode 100644 index 0000000..6a601eb --- /dev/null +++ b/apps/server/src/modules/cv-parser/persistence.service.ts @@ -0,0 +1,260 @@ +import { PrismaService } from "@cv/system"; +import { Injectable } from "@nestjs/common"; +import type { ParsedCVDataInput } from "./types/input.types"; + +/** + * Service for persisting parsed CV data to the database + * Handles entity resolution (creating or finding existing companies, institutions, etc.) + */ +@Injectable() +export class CVParserPersistenceService { + constructor(private prisma: PrismaService) {} + + /** + * Save parsed CV data to the database + * Creates job experiences and education entries + */ + async saveParsedData( + userId: string, + data: ParsedCVDataInput, + ): Promise<{ + jobExperiencesCreated: number; + educationCreated: number; + errors: string[]; + }> { + const result = { + jobExperiencesCreated: 0, + educationCreated: 0, + errors: [] as string[], + }; + + // Save job experiences + for (const job of data.jobExperiences) { + try { + await this.saveJobExperience(userId, job); + result.jobExperiencesCreated++; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown error"; + result.errors.push(`Job (${job.companyName}): ${message}`); + } + } + + // Save education entries + for (const edu of data.education) { + try { + await this.saveEducation(userId, edu); + result.educationCreated++; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown error"; + result.errors.push(`Education (${edu.institutionName}): ${message}`); + } + } + + return result; + } + + /** + * Save a single job experience + */ + private async saveJobExperience( + userId: string, + job: { + companyName: string; + roleName: string; + levelName?: string; + startDate: string; + endDate?: string; + description?: string; + skills: string[]; + }, + ) { + // Find or create company + let company = await this.prisma.company.findFirst({ + where: { + name: { + equals: job.companyName, + mode: "insensitive", + }, + }, + }); + + if (!company) { + company = await this.prisma.company.create({ + data: { + name: job.companyName, + }, + }); + } + + // Find or create role + let role = await this.prisma.role.findFirst({ + where: { + name: { + equals: job.roleName, + mode: "insensitive", + }, + }, + }); + + if (!role) { + role = await this.prisma.role.create({ + data: { + name: job.roleName, + }, + }); + } + + // Find or create level (with default "Mid-level" if not found) + let level = await this.prisma.level.findFirst({ + where: { + name: { + equals: job.levelName || "Mid-level", + mode: "insensitive", + }, + }, + }); + + if (!level) { + level = await this.prisma.level.create({ + data: { + name: job.levelName ?? "Mid-level", + }, + }); + } + + // Create job experience + const jobExperience = await this.prisma.userJobExperience.create({ + data: { + userId, + companyId: company.id, + roleId: role.id, + levelId: level.id, + startDate: new Date(job.startDate), + endDate: job.endDate ? new Date(job.endDate) : null, + description: job.description || null, + }, + }); + + // Add skills if provided + if (job.skills && job.skills.length > 0) { + const skillIds = await this.resolveSkillIds(job.skills); + + await this.prisma.userJobExperience.update({ + where: { id: jobExperience.id }, + data: { + skills: { + connect: skillIds.map((id) => ({ id })), + }, + }, + }); + } + } + + /** + * Save a single education entry + */ + private async saveEducation( + userId: string, + edu: { + institutionName: string; + degree: string; + fieldOfStudy?: string; + startDate: string; + endDate?: string; + description?: string; + skills: string[]; + }, + ) { + // Find or create institution + let institution = await this.prisma.institution.findFirst({ + where: { + name: { + equals: edu.institutionName, + mode: "insensitive", + }, + }, + }); + + if (!institution) { + institution = await this.prisma.institution.create({ + data: { + name: edu.institutionName, + }, + }); + } + + // Create education entry + const education = await this.prisma.education.create({ + data: { + userId, + institutionId: institution.id, + degree: edu.degree, + fieldOfStudy: edu.fieldOfStudy || null, + startDate: new Date(edu.startDate), + endDate: edu.endDate ? new Date(edu.endDate) : null, + description: edu.description || null, + }, + }); + + // Add skills if provided + if (edu.skills && edu.skills.length > 0) { + const skillIds = await this.resolveSkillIds(edu.skills); + + await this.prisma.education.update({ + where: { id: education.id }, + data: { + skills: { + connect: skillIds.map((id) => ({ id })), + }, + }, + }); + } + } + + /** + * Resolve skill names to IDs, creating new skills if needed + */ + private async resolveSkillIds(skillNames: string[]): Promise { + const uniqueSkills = [...new Set(skillNames)].filter((s) => s?.trim()); + + if (uniqueSkills.length === 0) { + return []; + } + + // Find existing skills + const existingSkills = await this.prisma.skill.findMany({ + where: { + name: { + in: uniqueSkills, + mode: "insensitive", + }, + }, + }); + + const existingSkillIds = new Set(existingSkills.map((s) => s.id)); + const existingSkillNames = new Set( + existingSkills.map((s) => s.name.toLowerCase()), + ); + + // Create missing skills + const skillsToCreate = uniqueSkills.filter( + (name) => !existingSkillNames.has(name.toLowerCase()), + ); + + const createdSkills = await Promise.all( + skillsToCreate.map((name) => + this.prisma.skill.create({ + data: { + name: name.trim(), + }, + }), + ), + ); + + const createdSkillIds = new Set(createdSkills.map((s) => s.id)); + + return [...Array.from(existingSkillIds), ...Array.from(createdSkillIds)]; + } +} diff --git a/apps/server/src/modules/cv-parser/types/draft.types.ts b/apps/server/src/modules/cv-parser/types/draft.types.ts new file mode 100644 index 0000000..ac2d177 --- /dev/null +++ b/apps/server/src/modules/cv-parser/types/draft.types.ts @@ -0,0 +1,162 @@ +import { Field, ID, ObjectType } from "@nestjs/graphql"; +import type { + DraftEntity, + ResolvedEducation, + ResolvedJobExperience, +} from "../entity-resolver.service"; + +/** + * Draft entity - represents an entity that may or may not exist in the database + * If id is null, the entity needs to be created on save + */ +@ObjectType() +export class DraftEntityType { + @Field(() => ID, { nullable: true }) + id: string | null; + + @Field() + name: string; + + constructor(id: string | null, name: string) { + this.id = id; + this.name = name; + } + + static fromDraft(draft: DraftEntity): DraftEntityType { + return new DraftEntityType(draft.id, draft.name); + } +} + +/** + * Draft job experience - parsed from CV with resolved entities + */ +@ObjectType() +export class DraftJobExperienceType { + @Field(() => DraftEntityType) + company: DraftEntityType; + + @Field(() => DraftEntityType) + role: DraftEntityType; + + @Field(() => DraftEntityType) + level: DraftEntityType; + + @Field(() => [DraftEntityType]) + skills: DraftEntityType[]; + + @Field() + startDate: Date; + + @Field(() => Date, { nullable: true }) + endDate: Date | null; + + @Field(() => String, { nullable: true }) + description: string | null; + + constructor( + company: DraftEntityType, + role: DraftEntityType, + level: DraftEntityType, + skills: DraftEntityType[], + startDate: Date, + endDate: Date | null, + description: string | null, + ) { + this.company = company; + this.role = role; + this.level = level; + this.skills = skills; + this.startDate = startDate; + this.endDate = endDate; + this.description = description; + } + + static fromResolved(resolved: ResolvedJobExperience): DraftJobExperienceType { + return new DraftJobExperienceType( + DraftEntityType.fromDraft(resolved.company), + DraftEntityType.fromDraft(resolved.role), + DraftEntityType.fromDraft(resolved.level), + resolved.skills.map(DraftEntityType.fromDraft), + resolved.startDate, + resolved.endDate, + resolved.description, + ); + } +} + +/** + * Draft education - parsed from CV with resolved entities + */ +@ObjectType() +export class DraftEducationType { + @Field(() => DraftEntityType) + institution: DraftEntityType; + + @Field() + degree: string; + + @Field(() => String, { nullable: true }) + fieldOfStudy: string | null; + + @Field(() => [DraftEntityType]) + skills: DraftEntityType[]; + + @Field() + startDate: Date; + + @Field(() => Date, { nullable: true }) + endDate: Date | null; + + @Field(() => String, { nullable: true }) + description: string | null; + + constructor( + institution: DraftEntityType, + degree: string, + fieldOfStudy: string | null, + skills: DraftEntityType[], + startDate: Date, + endDate: Date | null, + description: string | null, + ) { + this.institution = institution; + this.degree = degree; + this.fieldOfStudy = fieldOfStudy; + this.skills = skills; + this.startDate = startDate; + this.endDate = endDate; + this.description = description; + } + + static fromResolved(resolved: ResolvedEducation): DraftEducationType { + return new DraftEducationType( + DraftEntityType.fromDraft(resolved.institution), + resolved.degree, + resolved.fieldOfStudy, + resolved.skills.map(DraftEntityType.fromDraft), + resolved.startDate, + resolved.endDate, + resolved.description, + ); + } +} + +/** + * Parsed CV data with resolved entities + */ +@ObjectType() +export class ParsedCVDataWithResolutionType { + @Field(() => [DraftJobExperienceType]) + jobExperiences: DraftJobExperienceType[]; + + @Field(() => [DraftEducationType]) + education: DraftEducationType[]; + + constructor( + jobExperiences: DraftJobExperienceType[], + education: DraftEducationType[], + ) { + this.jobExperiences = jobExperiences; + this.education = education; + } +} diff --git a/apps/server/src/modules/cv-parser/types/input.types.ts b/apps/server/src/modules/cv-parser/types/input.types.ts new file mode 100644 index 0000000..adf068c --- /dev/null +++ b/apps/server/src/modules/cv-parser/types/input.types.ts @@ -0,0 +1,79 @@ +import { Field, InputType } from "@nestjs/graphql"; + +/** + * Input types for the approveParsedData mutation + * These accept raw string names (not entity IDs) and the persistence layer + * handles find-or-create for entities + */ + +@InputType() +export class ParsedPersonalInfoInput { + @Field({ nullable: true }) + name?: string; + + @Field({ nullable: true }) + introduction?: string; +} + +@InputType() +export class ParsedJobExperienceInput { + @Field() + companyName: string = ""; + + @Field() + roleName: string = ""; + + @Field({ nullable: true }) + levelName?: string; + + @Field() + startDate: string = ""; + + @Field({ nullable: true }) + endDate?: string; + + @Field({ nullable: true }) + description?: string; + + @Field(() => [String]) + skills: string[] = []; +} + +@InputType() +export class ParsedEducationInput { + @Field() + institutionName: string = ""; + + @Field() + degree: string = ""; + + @Field({ nullable: true }) + fieldOfStudy?: string; + + @Field() + startDate: string = ""; + + @Field({ nullable: true }) + endDate?: string; + + @Field({ nullable: true }) + description?: string; + + @Field(() => [String]) + skills: string[] = []; +} + +@InputType() +export class ParsedCVDataInput { + @Field(() => ParsedPersonalInfoInput, { nullable: true }) + personalInfo?: ParsedPersonalInfoInput; + + @Field(() => [ParsedJobExperienceInput]) + jobExperiences: ParsedJobExperienceInput[] = []; + + @Field(() => [ParsedEducationInput]) + education: ParsedEducationInput[] = []; + + @Field(() => [String]) + skills: string[] = []; +} diff --git a/apps/server/test/auth-integration.e2e-spec.ts b/apps/server/test/auth-integration.e2e-spec.ts index f69d7c0..d549e91 100644 --- a/apps/server/test/auth-integration.e2e-spec.ts +++ b/apps/server/test/auth-integration.e2e-spec.ts @@ -42,7 +42,6 @@ describe("Auth Integration Tests (e2e)", () => { }); expect(registerResponse.body.data.register).toBeDefined(); - expect(registerResponse.body.data.register.access_token).toBeDefined(); expect(registerResponse.body.data.register.user.email).toBe( newTestUser.email, ); @@ -50,7 +49,8 @@ describe("Auth Integration Tests (e2e)", () => { newTestUser.name, ); - const { access_token } = registerResponse.body.data.register; + const cookies = registerResponse.headers["set-cookie"]; + expect(cookies).toBeDefined(); // Step 2: Login with the registered user const loginResponse = await makeUnauthenticatedRequest(app) @@ -64,12 +64,12 @@ describe("Auth Integration Tests (e2e)", () => { .expect(200); expect(loginResponse.body.data.login).toBeDefined(); - expect(loginResponse.body.data.login.access_token).toBeDefined(); expect(loginResponse.body.data.login.user.email).toBe(newTestUser.email); expect(loginResponse.body.data.login.user.name).toBe(newTestUser.name); - // Step 3: Use the token to get current user info - const meResponse = await makeAuthenticatedRequest(app, access_token) + // Step 3: Use the cookies to get current user info + const loginCookies = loginResponse.headers["set-cookie"]; + const meResponse = await makeAuthenticatedRequest(app, loginCookies) .send({ query: BASIC_ME_QUERY, }) @@ -89,7 +89,7 @@ describe("Auth Integration Tests (e2e)", () => { .expect(200); expect(response.body.errors).toBeDefined(); - expect(response.body.errors[0].message).toContain("No token provided"); + expect(response.body.errors[0].message).toContain("No authentication token provided"); }); it("should fail login with invalid credentials", async () => { @@ -146,7 +146,7 @@ describe("Auth Integration Tests (e2e)", () => { describe("Me Query Optimization", () => { it("should return basic user info without loading organizations", async () => { - const response = await makeAuthenticatedRequest(app, testUser.accessToken) + const response = await makeAuthenticatedRequest(app, testUser.cookies) .send({ query: BASIC_ME_QUERY, }) @@ -163,7 +163,7 @@ describe("Auth Integration Tests (e2e)", () => { }); it("should load organizations when explicitly requested", async () => { - const response = await makeAuthenticatedRequest(app, testUser.accessToken) + const response = await makeAuthenticatedRequest(app, testUser.cookies) .send({ query: ME_WITH_ORGANIZATIONS_QUERY, }) @@ -181,7 +181,7 @@ describe("Auth Integration Tests (e2e)", () => { }); it("should handle partial organization field requests", async () => { - const response = await makeAuthenticatedRequest(app, testUser.accessToken) + const response = await makeAuthenticatedRequest(app, testUser.cookies) .send({ query: ME_WITH_ORGANIZATIONS_QUERY, }) @@ -203,7 +203,7 @@ describe("Auth Integration Tests (e2e)", () => { }); it("should return empty organizations array for user with no organizations", async () => { - const response = await makeAuthenticatedRequest(app, testUser.accessToken) + const response = await makeAuthenticatedRequest(app, testUser.cookies) .send({ query: ME_WITH_ORGANIZATIONS_QUERY, }) @@ -224,7 +224,7 @@ describe("Auth Integration Tests (e2e)", () => { describe("Complex User Query with Organizations", () => { it("should return user with organizations", async () => { - const response = await makeAuthenticatedRequest(app, testUser.accessToken) + const response = await makeAuthenticatedRequest(app, testUser.cookies) .send({ query: COMPLEX_ME_QUERY, }) @@ -262,7 +262,7 @@ describe("Auth Integration Tests (e2e)", () => { .expect(200); expect(response.body.errors).toBeDefined(); - expect(response.body.errors[0].message).toContain("No token provided"); + expect(response.body.errors[0].message).toContain("No authentication token provided"); }); }); }); diff --git a/apps/server/test/auth.e2e-spec.ts b/apps/server/test/auth.e2e-spec.ts index 7d72cb5..6f656ab 100644 --- a/apps/server/test/auth.e2e-spec.ts +++ b/apps/server/test/auth.e2e-spec.ts @@ -68,13 +68,14 @@ describe("Authentication Flow (e2e)", () => { } expect(registerResponse.body.data.register).toBeDefined(); - expect(registerResponse.body.data.register.access_token).toBeDefined(); expect(registerResponse.body.data.register.user.email).toBe( testUser.email, ); expect(registerResponse.body.data.register.user.name).toBe(testUser.name); - const { access_token } = registerResponse.body.data.register; + // Extract cookies from the response + const cookies = registerResponse.headers["set-cookie"]; + expect(cookies).toBeDefined(); // Step 2: Login with the registered user const loginResponse = await request(app.getHttpServer()) @@ -89,14 +90,14 @@ describe("Authentication Flow (e2e)", () => { .expect(200); expect(loginResponse.body.data.login).toBeDefined(); - expect(loginResponse.body.data.login.access_token).toBeDefined(); expect(loginResponse.body.data.login.user.email).toBe(testUser.email); expect(loginResponse.body.data.login.user.name).toBe(testUser.name); - // Step 3: Use the token to get current user info + // Step 3: Use the cookies to get current user info + const loginCookies = loginResponse.headers["set-cookie"]; const meResponse = await request(app.getHttpServer()) .post("/graphql") - .set("Authorization", `Bearer ${access_token}`) + .set("Cookie", loginCookies) .send({ query: BASIC_ME_QUERY, }) @@ -117,7 +118,7 @@ describe("Authentication Flow (e2e)", () => { .expect(200); expect(response.body.errors).toBeDefined(); - expect(response.body.errors[0].message).toContain("No token provided"); + expect(response.body.errors[0].message).toContain("No authentication token provided"); }); it("should fail login with invalid credentials", async () => { diff --git a/apps/server/test/coverage-unit/lcov-report/index.html b/apps/server/test/coverage-unit/lcov-report/index.html index b7c8259..cebf455 100644 --- a/apps/server/test/coverage-unit/lcov-report/index.html +++ b/apps/server/test/coverage-unit/lcov-report/index.html @@ -86,7 +86,7 @@