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
12 changes: 12 additions & 0 deletions .github/workflows/server-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,24 @@ jobs:
--health-timeout 5s
--health-retries 5

redis:
image: redis:7-alpine
ports:
- "6379:6379"
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5

env:
DATABASE_URL: postgresql://test:test@localhost:5432/dookmark_test
JWT_SECRET: test-jwt-secret
GOOGLE_CLIENT_ID: dummy-client-id
GOOGLE_CLIENT_SECRET: dummy-client-secret
GOOGLE_CALLBACK_URL: http://localhost:3001/auth/google/callback
REDIS_HOST: localhost
REDIS_PORT: 6379
NODE_ENV: test

steps:
Expand Down
6 changes: 6 additions & 0 deletions apps/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,9 @@ FRONTEND_URL="https://YOUR_FRONTEND_DOMAIN"

# Runtime environment
NODE_ENV=production

# Redis configuration
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_TLS=false
6 changes: 5 additions & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"test": "jest",
"test": "jest --forceExit",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
Expand Down Expand Up @@ -40,11 +40,13 @@
"@nestjs/websockets": "^11.1.27",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.6.0",
"@socket.io/redis-adapter": "^8.3.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"helmet": "^8.2.0",
"ioredis": "^5.11.1",
"nodemailer": "^9.0.0",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
Expand All @@ -63,6 +65,7 @@
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0",
"@types/ioredis-mock": "^8.2.7",
"@types/jest": "^30.0.0",
"@types/node": "^22.19.15",
"@types/nodemailer": "^8.0.1",
Expand All @@ -71,6 +74,7 @@
"@types/pg": "^8.20.0",
"@types/supertest": "^6.0.2",
"globals": "^16.0.0",
"ioredis-mock": "^8.13.1",
"jest": "^30.0.0",
"prisma": "^7.6.0",
"source-map-support": "^0.5.21",
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';
import { BookmarkModule } from './bookmark/bookmark.module';
import { PrismaExceptionFilter } from './common/filters/prisma-exception.filter';
import { RedisModule } from './common/redis/redis.module';
import { validateEnv } from './config/env.validation';
import { PrismaModule } from './prisma/prisma.module';
import { SessionModule } from './session/session.module';
Expand All @@ -18,6 +19,7 @@ import { UserModule } from './user/user.module';
imports: [
ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }),
PrismaModule,
RedisModule,
UserModule,
SessionModule,
AuthModule,
Expand Down
122 changes: 74 additions & 48 deletions apps/server/src/bookmark/gateways/bookmark.gateway.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,39 @@ import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test, type TestingModule } from '@nestjs/testing';
import { BrowserType } from '@prisma/client';
import type { Socket } from 'socket.io';
import type { Server, Socket } from 'socket.io';
import type { PartialServiceMock, ServiceMock } from '../../../test/mock.types';
import { RedisService } from '../../common/redis/redis.service';
import { BookmarkGateway } from './bookmark.gateway';

const userId = 'user-id';

// 테스트용 가짜 Socket (handshake/ disconnect/ emit만 사용)
// 테스트용 가짜 Socket (handshake/ disconnect/ emit/ join/ to 사용)
const makeSocket = (id: string, opts: { token?: string; browser?: string } = {}): Socket => {
const { token = 'valid-token', browser = BrowserType.CHROME } = opts;
const mockEmit = jest.fn();
const mockDisconnectSockets = jest.fn();
const mockTo = jest.fn().mockReturnValue({
disconnectSockets: mockDisconnectSockets,
});

return {
id,
handshake: { auth: { token }, query: { browser } },
disconnect: jest.fn(),
emit: jest.fn(),
emit: mockEmit,
join: jest.fn(),
to: mockTo,
} as unknown as Socket;
};

describe('BookmarkGateway', () => {
let gateway: BookmarkGateway;
let jwtMock: ServiceMock<JwtService>;
let redisMock: ServiceMock<RedisService>;
let mockEmit: jest.Mock;
let mockExcept: jest.Mock;
let mockTo: jest.Mock;

beforeEach(async () => {
// 노이즈 억제: 연결/해제 시 다량의 로그가 출력된다
Expand All @@ -33,12 +46,36 @@ describe('BookmarkGateway', () => {
verify: jest.fn().mockReturnValue({ sub: userId }),
};

const mockRedis: PartialServiceMock<RedisService> = {
zadd: jest.fn(),
zrem: jest.fn(),
zremrangebyscore: jest.fn(),
zrange: jest.fn(),
};

const module: TestingModule = await Test.createTestingModule({
providers: [BookmarkGateway, { provide: JwtService, useValue: mockJwt }],
providers: [
BookmarkGateway,
{ provide: JwtService, useValue: mockJwt },
{ provide: RedisService, useValue: mockRedis },
],
}).compile();

gateway = module.get<BookmarkGateway>(BookmarkGateway);
jwtMock = module.get<ServiceMock<JwtService>>(JwtService);
redisMock = module.get<ServiceMock<RedisService>>(RedisService);

// Socket.io Server 모킹 설정
mockEmit = jest.fn();
mockExcept = jest.fn().mockReturnValue({ emit: mockEmit });
mockTo = jest.fn().mockReturnValue({
except: mockExcept,
emit: mockEmit,
});

gateway.server = {
to: mockTo,
} as unknown as Server;
});

afterEach(() => {
Expand Down Expand Up @@ -82,91 +119,80 @@ describe('BookmarkGateway', () => {
expect(socket.disconnect).toHaveBeenCalled();
});

it('정상 연결은 끊기지 않고 등록되어 알림 대상이 된다', async () => {
it('정상 연결은 끊기지 않고 룸 가입 및 ZSET 등록이 진행된다', async () => {
const socket = makeSocket('s1', { browser: BrowserType.FIREFOX });

await gateway.handleConnection(socket);

expect(socket.disconnect).not.toHaveBeenCalled();
// 다른 브라우저(CHROME) 소스 업데이트 → FIREFOX 연결로 알림이 가야 한다
gateway.notifyBookmarkUpdate(userId, BrowserType.CHROME);
expect(socket.emit).toHaveBeenCalledWith('bookmark_updated', { browser: BrowserType.CHROME });
expect(socket.join).toHaveBeenCalledWith(`user:${userId}`);
expect(socket.join).toHaveBeenCalledWith(`user:${userId}:${BrowserType.FIREFOX}`);
expect(redisMock.zadd).toHaveBeenCalledWith(
`user:${userId}:active_browsers`,
expect.any(Number),
BrowserType.FIREFOX,
);
});

it('같은 브라우저의 기존 연결이 있으면 끊고 새 연결로 교체한다', async () => {
const oldSocket = makeSocket('old', { browser: BrowserType.CHROME });
const newSocket = makeSocket('new', { browser: BrowserType.CHROME });
it('같은 브라우저의 기존 연결이 있으면 disconnectSockets를 호출한다', async () => {
const socket = makeSocket('new', { browser: BrowserType.CHROME });

await gateway.handleConnection(oldSocket);
await gateway.handleConnection(newSocket);
await gateway.handleConnection(socket);

expect(oldSocket.disconnect).toHaveBeenCalled();
expect(socket.to).toHaveBeenCalledWith(`user:${userId}:${BrowserType.CHROME}`);
const mockToResult = socket.to(`user:${userId}:${BrowserType.CHROME}`);
expect(mockToResult.disconnectSockets).toHaveBeenCalledWith(true);
});
});

describe('handleDisconnect', () => {
it('연결을 제거하여 더 이상 알림을 받지 않는다', async () => {
it('연결 해제 시 ZSET에서 제거하고 타이머를 해제한다', async () => {
const socket = makeSocket('s1', { browser: BrowserType.FIREFOX });
await gateway.handleConnection(socket);

gateway.handleDisconnect(socket);
await gateway.handleDisconnect(socket);

gateway.notifyBookmarkUpdate(userId, BrowserType.CHROME);
expect(socket.emit).not.toHaveBeenCalled();
expect(redisMock.zrem).toHaveBeenCalledWith(
`user:${userId}:active_browsers`,
BrowserType.FIREFOX,
);
});
});

describe('notifyBookmarkUpdate', () => {
it('소스 브라우저 자신에게는 알림을 보내지 않는다', async () => {
const chrome = makeSocket('c', { browser: BrowserType.CHROME });
await gateway.handleConnection(chrome);

it('소스 브라우저를 제외하고 해당 유저 룸에 이벤트를 보낸다', () => {
gateway.notifyBookmarkUpdate(userId, BrowserType.CHROME);

expect(chrome.emit).not.toHaveBeenCalled();
});

it('연결된 사용자가 없으면 아무 일도 하지 않는다', () => {
expect(() => gateway.notifyBookmarkUpdate('ghost-user', BrowserType.CHROME)).not.toThrow();
expect(mockTo).toHaveBeenCalledWith(`user:${userId}`);
expect(mockExcept).toHaveBeenCalledWith(`user:${userId}:${BrowserType.CHROME}`);
expect(mockEmit).toHaveBeenCalledWith('bookmark_updated', { browser: BrowserType.CHROME });
});
});

describe('notifyTrashEmptied', () => {
it('해당 사용자의 모든 연결에 휴지통 비움을 알린다', async () => {
const chrome = makeSocket('c', { browser: BrowserType.CHROME });
const firefox = makeSocket('f', { browser: BrowserType.FIREFOX });
await gateway.handleConnection(chrome);
await gateway.handleConnection(firefox);

it('해당 사용자 룸 전체에 휴지통 비움을 알린다', () => {
gateway.notifyTrashEmptied(userId);

expect(chrome.emit).toHaveBeenCalledWith('bookmark_trash_emptied');
expect(firefox.emit).toHaveBeenCalledWith('bookmark_trash_emptied');
expect(mockTo).toHaveBeenCalledWith(`user:${userId}`);
expect(mockEmit).toHaveBeenCalledWith('bookmark_trash_emptied');
});
});

describe('sendCrossBrowserMove / sendNativeAction', () => {
it('지정한 대상 브라우저 연결에만 이동 명령을 보낸다', async () => {
const chrome = makeSocket('c', { browser: BrowserType.CHROME });
const firefox = makeSocket('f', { browser: BrowserType.FIREFOX });
await gateway.handleConnection(chrome);
await gateway.handleConnection(firefox);

it('지정한 대상 브라우저 전용 룸에 이동 명령을 보낸다', () => {
const payload = { action: 'remove' as const, id: 'src' };
gateway.sendCrossBrowserMove(userId, BrowserType.FIREFOX, payload);

expect(firefox.emit).toHaveBeenCalledWith('bookmark_cross_moved', payload);
expect(chrome.emit).not.toHaveBeenCalled();
expect(mockTo).toHaveBeenCalledWith(`user:${userId}:${BrowserType.FIREFOX}`);
expect(mockEmit).toHaveBeenCalledWith('bookmark_cross_moved', payload);
});

it('지정한 대상 브라우저 연결에만 네이티브 액션을 보낸다', async () => {
const chrome = makeSocket('c', { browser: BrowserType.CHROME });
await gateway.handleConnection(chrome);

it('지정한 대상 브라우저 전용 룸에 네이티브 액션을 보낸다', () => {
const payload = { action: 'delete' as const, browserBookmarkId: 'node-id' };
gateway.sendNativeAction(userId, BrowserType.CHROME, payload);

expect(chrome.emit).toHaveBeenCalledWith('bookmark_native_action', payload);
expect(mockTo).toHaveBeenCalledWith(`user:${userId}:${BrowserType.CHROME}`);
expect(mockEmit).toHaveBeenCalledWith('bookmark_native_action', payload);
});
});
});
Loading
Loading