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
36 changes: 31 additions & 5 deletions src/prisma/prisma.module.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,39 @@
import { Global, Module } from '@nestjs/common';
import {
Global,
Module,
type OnModuleDestroy,
type OnModuleInit,
} from '@nestjs/common';

import { PrismaService } from '@/prisma/prisma.service';
import {
createExtendedPrismaClient,
PrismaService,
} from '@/prisma/prisma.service';

/**
* Prisma 모듈
* Prisma 모듈.
*
* - PrismaService 토큰은 useFactory 로 확장(soft-delete) 적용된 PrismaClient 인스턴스를 제공한다.
* - 클라이언트의 connect/disconnect 라이프사이클은 본 모듈이 소유한다.
*/
@Global()
@Module({
providers: [PrismaService],
providers: [
{
provide: PrismaService,
useFactory: createExtendedPrismaClient,
},
],
exports: [PrismaService],
})
export class PrismaModule {}
export class PrismaModule implements OnModuleInit, OnModuleDestroy {
constructor(private readonly prisma: PrismaService) {}

async onModuleInit(): Promise<void> {
await this.prisma.$connect();
}

async onModuleDestroy(): Promise<void> {
await this.prisma.$disconnect();
}
}
140 changes: 88 additions & 52 deletions src/prisma/prisma.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { PrismaService } from '@/prisma/prisma.service';
import { Test } from '@nestjs/testing';

import { PrismaModule } from '@/prisma/prisma.module';
import {
createExtendedPrismaClient,
PrismaService,
} from '@/prisma/prisma.service';
import {
disconnectTestPrismaClient,
getTestDatabaseUrl,
Expand All @@ -7,14 +13,17 @@ import {
import { closeTruncateConnection, truncateAll } from '@/test/db/truncate';

/**
* PrismaService는 기본 DATABASE_URL을 사용하는 Prisma 클라이언트.
* 테스트에서는 프로세스의 DATABASE_URL을 test container URL로 치환한 뒤 생성한다.
* Prisma 모듈/서비스 — useFactory + class+interface declaration merging 패턴 검증.
*
* - createExtendedPrismaClient: 확장(soft-delete) 적용된 PrismaClient 인스턴스 생성
* - PrismaModule: 인스턴스 라이프사이클 (connect/disconnect) 소유
* - DI 토큰으로 사용된 PrismaService 클래스 → 실제 주입되는 인스턴스는 factory 반환값
*/
describe('PrismaService (real DB)', () => {
describe('Prisma (real DB)', () => {
let originalDatabaseUrl: string | undefined;

beforeAll(async () => {
// test container가 기동되도록 한 번 호출하여 URL 확보
// test container 기동 + DATABASE_URL 치환 (createExtendedPrismaClient 가 이 URL 을 사용)
await getTestPrismaClient();
originalDatabaseUrl = process.env.DATABASE_URL;
process.env.DATABASE_URL = getTestDatabaseUrl();
Expand All @@ -31,56 +40,83 @@ describe('PrismaService (real DB)', () => {
await truncateAll();
});

it('onModuleInit이 connect를 수행하고 쿼리 실행이 가능하다', async () => {
const service = new PrismaService();
await service.onModuleInit();
try {
// softDelete extension이 적용되어 있으면 Account 쿼리가 정상 작동
const count = await service.account.count();
expect(typeof count).toBe('number');
} finally {
await service.onModuleDestroy();
}
});
describe('createExtendedPrismaClient', () => {
it('확장 적용된 Prisma 클라이언트를 반환한다 + 기본 쿼리 동작', async () => {
const client = createExtendedPrismaClient();
await client.$connect();
try {
const count = await client.account.count();
expect(typeof count).toBe('number');
} finally {
await client.$disconnect();
}
});

it('softDelete 확장이 적용되어 deleted_at 이 null 인 row 만 자동 필터한다', async () => {
const client = createExtendedPrismaClient();
await client.$connect();
try {
const active = await client.account.create({
data: {
account_type: 'USER',
status: 'ACTIVE',
email: 'active@test.com',
name: 'A',
},
});
await client.account.create({
data: {
account_type: 'USER',
status: 'ACTIVE',
email: 'deleted@test.com',
name: 'D',
deleted_at: new Date(),
},
});

it('onModuleDestroy가 disconnect까지 수행한다 (연속 호출해도 throw 없음)', async () => {
const service = new PrismaService();
await service.onModuleInit();
await service.onModuleDestroy();
// 재호출이 no-op처럼 동작하는지 (Prisma는 idempotent disconnect)
await expect(service.onModuleDestroy()).resolves.not.toThrow();
const found = await client.account.findMany({
where: { email: { in: ['active@test.com', 'deleted@test.com'] } },
});
// 자동으로 deleted_at: null 필터가 주입되어 active 만 반환
expect(found.map((a) => a.id)).toEqual([active.id]);
} finally {
await client.$disconnect();
}
});
});

it('생성된 인스턴스에 softDelete extension이 적용되어 있다 (deleted_at 자동 필터)', async () => {
const service = new PrismaService();
await service.onModuleInit();
try {
// soft-delete된 account를 생성하고 findFirst로는 안 나오는 것을 확인
const active = await service.account.create({
data: {
account_type: 'USER',
status: 'ACTIVE',
email: 'active@test.com',
name: 'A',
},
});
await service.account.create({
data: {
account_type: 'USER',
status: 'ACTIVE',
email: 'deleted@test.com',
name: 'D',
deleted_at: new Date(),
},
});
describe('PrismaModule (라이프사이클 owner)', () => {
it('모듈 init 시 $connect, destroy 시 $disconnect 가 호출된다', async () => {
const moduleRef = await Test.createTestingModule({
imports: [PrismaModule],
}).compile();
// compile 단계에서 useFactory 가 즉시 호출되어 클라이언트 인스턴스가 생성된다.

const prisma = moduleRef.get(PrismaService);
const connectSpy = jest.spyOn(prisma, '$connect');
const disconnectSpy = jest.spyOn(prisma, '$disconnect');

// init/close 를 호출하면 모듈 라이프사이클 훅이 동작한다.
await moduleRef.init();
expect(connectSpy).toHaveBeenCalledTimes(1);

await moduleRef.close();
expect(disconnectSpy).toHaveBeenCalledTimes(1);
});

const found = await service.account.findMany({
where: { email: { in: ['active@test.com', 'deleted@test.com'] } },
});
// softDelete extension이 자동으로 deleted_at: null 필터를 붙이므로 active만 반환
expect(found.map((a) => a.id)).toEqual([active.id]);
} finally {
await service.onModuleDestroy();
}
it('PrismaService 토큰으로 주입된 인스턴스는 확장 적용된 클라이언트이다 (account 모델 접근 가능)', async () => {
const moduleRef = await Test.createTestingModule({
imports: [PrismaModule],
}).compile();
await moduleRef.init();
try {
const prisma = moduleRef.get(PrismaService);
// 확장 클라이언트는 모든 model accessor 를 그대로 노출한다.
expect(typeof prisma.account.count).toBe('function');
await prisma.account.count();
} finally {
await moduleRef.close();
}
});
});
});
52 changes: 16 additions & 36 deletions src/prisma/prisma.service.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,23 @@
import {
Injectable,
type OnModuleInit,
type OnModuleDestroy,
} from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

import { softDeleteExtension } from '@/prisma/soft-delete.middleware';

/**
* Prisma 클라이언트를 NestJS DI 컨테이너에 제공하는 서비스
* 확장(soft-delete) 이 적용된 Prisma 클라이언트를 생성한다.
*
* NestJS provider 의 `useFactory` 로 호출되어 단일 인스턴스를 만든다.
* 라이프사이클(connect/disconnect) 은 PrismaModule 이 owner.
*/
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
constructor() {
super();
const extended = this.$extends(softDeleteExtension) as PrismaService;
extended.onModuleInit = async () => {
await extended.$connect();
};
extended.onModuleDestroy = async () => {
await extended.$disconnect();
};
return extended;
}

/**
* 모듈 초기화 시 Prisma 클라이언트 연결
*/
async onModuleInit(): Promise<void> {
await this.$connect();
}

/**
* 모듈 종료 시 Prisma 클라이언트 연결 해제
*/
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
export function createExtendedPrismaClient() {
return new PrismaClient().$extends(softDeleteExtension);
}

/**
* NestJS DI 토큰 역할의 abstract class.
*
* - `new PrismaService()` 호출은 abstract 라 compile error → 잘못된 인스턴스화 원천 차단
* - `extends PrismaClient` 로 model accessor 등 API 타입을 그대로 노출
* → callsite 는 `prisma.account.findMany(...)` 등을 그대로 사용
* - 실제 주입되는 인스턴스는 PrismaModule 의 `useFactory` 가 반환한 `createExtendedPrismaClient()` 결과
*/
export abstract class PrismaService extends PrismaClient {}
Loading