Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ Legend:
- POST `/portfolio-corrections` -> IMPLEMENTED (`title` required, consumes tickets, can return `TICKET402`)
- GET `/portfolio-corrections/{correctionId}/status` -> IMPLEMENTED
- GET `/portfolio-corrections/{correctionId}/company-insight` -> IMPLEMENTED
- GET `/portfolio-corrections/{correctionId}` -> IMPLEMENTED
- GET `/portfolio-corrections/{correctionId}` -> IMPLEMENTED (응답: `CorrectionResultResDTO`, `status: CorrectionStatus` 포함)
- POST `/portfolio-corrections/{correctionId}/company-insight` -> IMPLEMENTED
- PATCH `/portfolio-corrections/{correctionId}/company-insight` -> IMPLEMENTED
- POST `/portfolio-corrections/{correctionId}/select` -> IMPLEMENTED (선택 포트폴리오를 매핑 테이블에 활성화 상태로 저장)
Expand All @@ -266,7 +266,7 @@ Legend:

### External Portfolios

- GET `/external-portfolios?correctionId=...` -> IMPLEMENTED
- GET `/external-portfolios?correctionId=...` -> IMPLEMENTED (응답: `ExternalPortfolioListResDTO` = `{ status: PdfExtractionStatus, originalFileName: string \| null, portfolios: StructuredPortfolioResDTO[] }`)
- POST `/external-portfolios` -> IMPLEMENTED
- POST `/external-portfolios/extract` -> IMPLEMENTED (multipart PDF + `correctionId`, busboy 파싱, AI 추출 후 DB 저장)
- PATCH `/external-portfolios/{portfolioId}` -> IMPLEMENTED
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PortfolioCorrection } from '../../domain/portfolio-correction.entity';
import { CorrectionItem } from '../../domain/correction-item.entity';
import { CorrectionStatus } from '../../domain/enums/correction-status.enum';

type JsonPrimitive = string | number | boolean | null;
type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
Expand All @@ -13,6 +14,7 @@ type ProblemSolvingPayload = JsonObject;
type LearningsPayload = JsonObject;

export class CorrectionResultResDTO {
status: CorrectionStatus;
companyName: string;
positionName: string;
jobDescription: string;
Expand All @@ -23,6 +25,7 @@ export class CorrectionResultResDTO {

static from(correction: PortfolioCorrection, items: CorrectionItem[]): CorrectionResultResDTO {
const dto = new CorrectionResultResDTO();
dto.status = correction.status;
dto.companyName = correction.companyName;
dto.positionName = correction.positionName;
dto.jobDescription = correction.jobDescription;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsInt, IsOptional, IsPositive, IsString, MaxLength, MinLength } from 'class-validator';
import { Portfolio } from 'src/modules/portfolio/domain/portfolio.entity';
import { PdfExtractionStatus } from '../../domain/enums/pdf-extraction-status.enum';
import { normalizeOriginalFileName } from '../../common/utils/original-file-name-normalizer.util';

export class StructuredPortfolioResDTO {
portfolioId: number;
Expand All @@ -22,6 +25,31 @@ export class StructuredPortfolioResDTO {
}
}

export class ExternalPortfolioListResDTO {
@ApiProperty({ enum: PdfExtractionStatus })
status: PdfExtractionStatus;

@ApiProperty({ nullable: true })
originalFileName: string | null;

@ApiProperty({ type: [StructuredPortfolioResDTO] })
portfolios: StructuredPortfolioResDTO[];

static from(
status: PdfExtractionStatus,
originalFileName: string | null,
portfolios: Portfolio[]
): ExternalPortfolioListResDTO {
const dto = new ExternalPortfolioListResDTO();
dto.status = status;
dto.originalFileName = originalFileName
? normalizeOriginalFileName(originalFileName)
: null;
dto.portfolios = portfolios.map((portfolio) => StructuredPortfolioResDTO.from(portfolio));
return dto;
}
}

export class CreateExternalPortfolioReqDTO {
@IsInt()
@IsPositive()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,48 +12,189 @@ import { PortfolioCorrectionService } from '../services/portfolio-correction.ser
import { CorrectionPortfolioSelectionService } from '../services/correction-portfolio-selection.service';
import { CorrectionItemService } from '../services/correction-item.service';
import { PdfExtractService } from '../services/pdf-extract.service';
import { PdfExtractionStatus } from '../../domain/enums/pdf-extraction-status.enum';

class ExternalPortfolioServiceStub {
readonly deleteExternalPortfolio = jest.fn<Promise<void>, [number, number]>();
}

class PortfolioServiceStub {
readonly findByIdsAndUserIdOrThrow = jest.fn<Promise<unknown[]>, [number[], number]>();
readonly findByIds = jest.fn<Promise<unknown[]>, [number[]]>();
}

class PortfolioCorrectionServiceStub {}
class PortfolioCorrectionServiceStub {
readonly findByIdAndUserIdOrThrow = jest.fn<
Promise<{ pdfExtractionStatus: PdfExtractionStatus; originalFileName: string | null }>,
[number, number]
>();
readonly updatePdfExtractionStatus = jest.fn<Promise<void>, [number, PdfExtractionStatus]>();
readonly updateOriginalFileName = jest.fn<Promise<void>, [number, string]>();
}

class CorrectionPortfolioSelectionServiceStub {
readonly deleteByPortfolioId = jest.fn<Promise<void>, [number]>();
readonly findActivePortfolioIdsByCorrectionId = jest.fn<Promise<number[]>, [number]>();
}

class CorrectionItemServiceStub {
readonly deleteByPortfolioId = jest.fn<Promise<void>, [number]>();
}

class PdfExtractServiceStub {}
class PdfExtractServiceStub {
readonly extractText = jest.fn<Promise<{ message: string }>, [number, Buffer, string]>();
}

describe('ExternalPortfolioFacade', () => {
let externalPortfolioFacade: ExternalPortfolioFacade;
let externalPortfolioServiceStub: ExternalPortfolioServiceStub;
let portfolioServiceStub: PortfolioServiceStub;
let portfolioCorrectionServiceStub: PortfolioCorrectionServiceStub;
let correctionPortfolioSelectionServiceStub: CorrectionPortfolioSelectionServiceStub;
let correctionItemServiceStub: CorrectionItemServiceStub;
let pdfExtractServiceStub: PdfExtractServiceStub;

beforeEach(() => {
externalPortfolioServiceStub = new ExternalPortfolioServiceStub();
portfolioServiceStub = new PortfolioServiceStub();
portfolioCorrectionServiceStub = new PortfolioCorrectionServiceStub();
correctionPortfolioSelectionServiceStub = new CorrectionPortfolioSelectionServiceStub();
correctionItemServiceStub = new CorrectionItemServiceStub();
pdfExtractServiceStub = new PdfExtractServiceStub();

externalPortfolioFacade = new ExternalPortfolioFacade(
externalPortfolioServiceStub as unknown as ExternalPortfolioService,
portfolioServiceStub as unknown as PortfolioService,
new PortfolioCorrectionServiceStub() as unknown as PortfolioCorrectionService,
portfolioCorrectionServiceStub as unknown as PortfolioCorrectionService,
correctionPortfolioSelectionServiceStub as unknown as CorrectionPortfolioSelectionService,
correctionItemServiceStub as unknown as CorrectionItemService,
new PdfExtractServiceStub() as unknown as PdfExtractService
pdfExtractServiceStub as unknown as PdfExtractService
);
});

it('stores original file name when extraction is accepted', async () => {
portfolioCorrectionServiceStub.findByIdAndUserIdOrThrow.mockResolvedValue({
pdfExtractionStatus: PdfExtractionStatus.NONE,
originalFileName: null,
});
pdfExtractServiceStub.extractText.mockResolvedValue({
message: 'ok',
});

const result = await externalPortfolioFacade.extractPortfolio(
9,
1,
Buffer.from('pdf'),
'resume.pdf'
);

expect(result).toBe('ok');
expect(portfolioCorrectionServiceStub.updatePdfExtractionStatus).toHaveBeenCalledWith(
1,
PdfExtractionStatus.GENERATING
);
expect(portfolioCorrectionServiceStub.updateOriginalFileName).toHaveBeenCalledWith(
1,
'resume.pdf'
);
});

it('returns top-level pdf extraction status with portfolios list', async () => {
portfolioCorrectionServiceStub.findByIdAndUserIdOrThrow.mockResolvedValue({
pdfExtractionStatus: PdfExtractionStatus.GENERATING,
originalFileName: 'portfolio.pdf',
});
correctionPortfolioSelectionServiceStub.findActivePortfolioIdsByCorrectionId.mockResolvedValue(
[10, 20]
);
portfolioServiceStub.findByIds.mockResolvedValue([
{
id: 10,
name: 'A',
description: 'D1',
responsibilities: 'R1',
problemSolving: 'P1',
learnings: 'L1',
},
{
id: 20,
name: 'B',
description: 'D2',
responsibilities: 'R2',
problemSolving: 'P2',
learnings: 'L2',
},
]);

const result = await externalPortfolioFacade.getSelectedPortfolios(1, 9);

expect(result.status).toBe(PdfExtractionStatus.GENERATING);
expect(result.originalFileName).toBe('portfolio.pdf');
expect(result.portfolios).toEqual([
{
portfolioId: 10,
name: 'A',
description: 'D1',
responsibilities: 'R1',
problemSolving: 'P1',
learnings: 'L1',
},
{
portfolioId: 20,
name: 'B',
description: 'D2',
responsibilities: 'R2',
problemSolving: 'P2',
learnings: 'L2',
},
]);
});

it('normalizes legacy mojibake original file name on get response', async () => {
portfolioCorrectionServiceStub.findByIdAndUserIdOrThrow.mockResolvedValue({
pdfExtractionStatus: PdfExtractionStatus.GENERATING,
originalFileName: '첨삭_이력서.pdf',
});
correctionPortfolioSelectionServiceStub.findActivePortfolioIdsByCorrectionId.mockResolvedValue(
[]
);

const result = await externalPortfolioFacade.getSelectedPortfolios(1, 9);

expect(result.originalFileName).toBe('첨삭_이력서.pdf');
expect(result.portfolios).toEqual([]);
});

it('normalizes NFD original file name to NFC on get response', async () => {
const nfcName = '첨삭_이력서.pdf';
const nfdName = nfcName.normalize('NFD');
portfolioCorrectionServiceStub.findByIdAndUserIdOrThrow.mockResolvedValue({
pdfExtractionStatus: PdfExtractionStatus.GENERATING,
originalFileName: nfdName,
});
correctionPortfolioSelectionServiceStub.findActivePortfolioIdsByCorrectionId.mockResolvedValue(
[]
);

const result = await externalPortfolioFacade.getSelectedPortfolios(1, 9);

expect(result.originalFileName).toBe(nfcName);
expect(result.originalFileName?.normalize('NFC')).toBe(result.originalFileName);
expect(result.originalFileName).not.toBe(nfdName);
});

it('sanitizes directory and control characters on get response', async () => {
portfolioCorrectionServiceStub.findByIdAndUserIdOrThrow.mockResolvedValue({
pdfExtractionStatus: PdfExtractionStatus.GENERATING,
originalFileName: 'C:\\fakepath\\folioo\u0000-report.pdf',
});
correctionPortfolioSelectionServiceStub.findActivePortfolioIdsByCorrectionId.mockResolvedValue(
[]
);

const result = await externalPortfolioFacade.getSelectedPortfolios(1, 9);

expect(result.originalFileName).toBe('folioo-report.pdf');
});

it('validates ownership first, then deletes related links and portfolio', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { CorrectionPortfolioSelectionService } from '../services/correction-port
import { CorrectionItemService } from '../services/correction-item.service';
import { PdfExtractService } from '../services/pdf-extract.service';
import {
ExternalPortfolioListResDTO,
StructuredPortfolioResDTO,
UpdatePortfolioBlockReqDTO,
} from '../dtos/external-portfolio.dto';
Expand Down Expand Up @@ -47,26 +48,38 @@ export class ExternalPortfolioFacade {
correctionId,
PdfExtractionStatus.GENERATING
);
await this.portfolioCorrectionService.updateOriginalFileName(correctionId, fileName);
return message;
}

async getSelectedPortfolios(
correctionId: number,
userId: number
): Promise<StructuredPortfolioResDTO[]> {
await this.portfolioCorrectionService.findByIdAndUserIdOrThrow(correctionId, userId);
): Promise<ExternalPortfolioListResDTO> {
const correction = await this.portfolioCorrectionService.findByIdAndUserIdOrThrow(
correctionId,
userId
);

const portfolioIds =
await this.correctionPortfolioSelectionService.findActivePortfolioIdsByCorrectionId(
correctionId
);

if (portfolioIds.length === 0) {
return [];
return ExternalPortfolioListResDTO.from(
correction.pdfExtractionStatus,
correction.originalFileName,
[]
);
}

const portfolios = await this.portfolioService.findByIds(portfolioIds);
return portfolios.map((portfolio) => StructuredPortfolioResDTO.from(portfolio));
return ExternalPortfolioListResDTO.from(
correction.pdfExtractionStatus,
correction.originalFileName,
portfolios
);
}

@Transactional()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ export class PortfolioCorrectionService {
await this.portfolioCorrectionRepository.save(correction);
}

async updateOriginalFileName(correctionId: number, originalFileName: string): Promise<void> {
const correction = await this.findByIdOrThrow(correctionId);
correction.originalFileName = originalFileName;
await this.portfolioCorrectionRepository.save(correction);
}

async getCorrectionDetail(
correctionId: number,
userId: number
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { normalizeOriginalFileName } from './original-file-name-normalizer.util';

describe('normalizeOriginalFileName', () => {
it('strips directory segments and control characters', () => {
expect(normalizeOriginalFileName('C:\\fakepath\\folioo\u0000-report.pdf')).toBe(
'folioo-report.pdf'
);
});

it('caps file name length to 255 while preserving extension when possible', () => {
const longBase = 'a'.repeat(300);
const result = normalizeOriginalFileName(`${longBase}.pdf`);

expect(result.length).toBe(255);
expect(result.endsWith('.pdf')).toBe(true);
});
});
Loading
Loading