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
1,342 changes: 1,339 additions & 3 deletions .pnp.cjs

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives",
"lint:fix": "TIMING=1 eslint . --ext ts,tsx --fix",
"type-check": "tsc --noEmit",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@boolti/api": "*",
Expand Down Expand Up @@ -54,12 +56,16 @@
"@boolti/eslint-config": "*",
"@boolti/typescript-config": "*",
"@emotion/babel-plugin": "^11.11.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.0.1",
"@types/js-cookie": "^3.0.6",
"@types/navermaps": "^3.7.9",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"jsdom": "^26.1.0",
"typescript": "^5.2.2",
"vite": "^5.0.8"
"vite": "^5.0.8",
"vitest": "^2.1.9"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// @vitest-environment jsdom
// 통합 테스트 목적:
// 1) EnteranceTable에서 연락처가 formatPhoneNumber 결과로 렌더링되는지 검증
// 2) 검색어가 boldText를 통해 <strong> 하이라이트로 렌더링되는지 검증
import React from 'react';
import { render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import EnteranceTable from './index';

type EnteranceRow = React.ComponentProps<typeof EnteranceTable>['data'][number];

vi.mock('@boolti/ui', () => ({
Button: (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type={props.type ?? 'button'} {...props} />
),
}));

vi.mock('./EnteranceTable.styles', () => ({
default: {
Container: (props: React.HTMLAttributes<HTMLTableElement>) => <table {...props} />,
HeaderRow: (props: React.HTMLAttributes<HTMLTableRowElement>) => <tr {...props} />,
HeaderItem: (props: React.ThHTMLAttributes<HTMLTableCellElement>) => <th {...props} />,
Row: (props: React.HTMLAttributes<HTMLTableRowElement>) => <tr {...props} />,
Item: (props: React.TdHTMLAttributes<HTMLTableCellElement>) => <td {...props} />,
Empty: (props: React.HTMLAttributes<HTMLTableCellElement>) => <td {...props} />,
ResetButton: (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type={props.type ?? 'button'} {...props} />
),
DisabledText: (props: React.HTMLAttributes<HTMLSpanElement>) => <span {...props} />,
SearchResult: (props: React.HTMLAttributes<HTMLSpanElement>) => <span {...props} />,
},
}));

describe('EnteranceTable integration', () => {
it('연락처를 포맷하고 검색어를 하이라이트한다', () => {
const row: EnteranceRow = {
id: 1,
csTicketId: 'T-100',
reservation: {
id: 10,
csReservationId: 22,
reservationHolder: {
name: '홍길동',
phoneNumber: '01012345678',
},
},
salesTicketType: {
id: 7,
ticketType: 'SALE',
ticketName: '일반석',
price: 10000,
},
createdAt: '2026-05-01T10:00:00',
usedAt: '2026-05-01T11:00:00',
};

const { container } = render(
<EnteranceTable data={[row]} searchText="1234" isEnteredTicket={false} />,
);

expect(container.querySelector('tbody')?.textContent).toContain('010-1234-5678');
expect(container.querySelector('strong')?.textContent).toBe('1234');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// @vitest-environment jsdom
// 통합 테스트 목적:
// 1) MobileCardList에서 이름/연락처 문자열이 formatPhoneNumber 결과로 렌더링되는지 검증
// 2) 검색어가 boldText를 통해 <strong> 하이라이트로 렌더링되는지 검증
import React from 'react';
import { render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import MobileCardList from './index';

vi.mock('@boolti/ui', () => ({
Badge: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));

vi.mock('./MobileCardList.style', () => ({
default: {
Container: (props: React.HTMLAttributes<HTMLDivElement> & { isEmpty?: boolean }) => {
const { isEmpty, ...domProps } = props;
return <div data-empty={isEmpty ? 'true' : 'false'} {...domProps} />;
},
CardItem: (props: React.HTMLAttributes<HTMLDivElement>) => <div {...props} />,
Row: (props: React.HTMLAttributes<HTMLDivElement>) => <div {...props} />,
DateText: (props: React.HTMLAttributes<HTMLDivElement>) => <div {...props} />,
UserInfoText: (props: React.HTMLAttributes<HTMLDivElement>) => <div {...props} />,
TicketInfoText: (props: React.HTMLAttributes<HTMLDivElement>) => <div {...props} />,
ResetButton: (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type={props.type ?? 'button'} {...props} />
),
TicketDetailTextWrap: (props: React.HTMLAttributes<HTMLDivElement>) => <div {...props} />,
TicketStatusText: (props: React.HTMLAttributes<HTMLDivElement>) => <div {...props} />,
},
}));

describe('MobileCardList integration', () => {
it('이름/연락처를 포맷해 렌더링하고 검색어를 하이라이트한다', () => {
const items = [
{
id: 1,
name: '홍길동',
phoneNumber: '01012345678',
ticketName: '일반석',
type: 'NORMAL' as const,
status: '방문 완료',
},
];

const { container } = render(
<MobileCardList
items={items}
searchText="1234"
emptyText="비어있음"
onClickReset={() => {}}
/>,
);

expect(container.textContent).toContain('홍길동 (010-1234-5678)');
expect(container.querySelector('strong')?.textContent).toBe('1234');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// @vitest-environment jsdom
// 통합 테스트 목적:
// 1) ReservationTable에서 연락처가 formatPhoneNumber 결과로 렌더링되는지 검증
// 2) 검색어가 boldText를 통해 <strong> 하이라이트로 렌더링되는지 검증
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ReservationWithTicketsResponse } from '@boolti/api';

import ReservationTable from './index';

vi.mock('react-tooltip', () => ({
Tooltip: () => null,
}));

vi.mock('@boolti/ui', () => ({
Button: (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type={props.type ?? 'button'} {...props} />
),
palette: {
grey: {
g90: '#111111',
},
},
}));

vi.mock('./ReservationTable.styles', () => ({
default: {
Container: (props: React.HTMLAttributes<HTMLTableElement>) => <table {...props} />,
HeaderRow: (props: React.HTMLAttributes<HTMLTableRowElement>) => <tr {...props} />,
HeaderItem: (props: React.ThHTMLAttributes<HTMLTableCellElement>) => <th {...props} />,
Empty: (props: React.HTMLAttributes<HTMLTableCellElement>) => <td {...props} />,
ResetButton: (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type={props.type ?? 'button'} {...props} />
),
Row: (props: React.HTMLAttributes<HTMLTableRowElement>) => <tr {...props} />,
Item: (props: React.TdHTMLAttributes<HTMLTableCellElement>) => <td {...props} />,
TooltipAnchor: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => <a {...props} />,
TooltipItemColumn: (props: React.HTMLAttributes<HTMLDivElement>) => <div {...props} />,
TooltipItemRow: (props: React.HTMLAttributes<HTMLDivElement>) => <div {...props} />,
DisabledText: (props: React.HTMLAttributes<HTMLSpanElement>) => <span {...props} />,
},
}));

describe('ReservationTable integration', () => {
it('연락처를 포맷해서 렌더링하고 검색어를 하이라이트한다', () => {
const row: ReservationWithTicketsResponse = {
reservationId: 1,
csReservationId: 1,
paymentManagementStatus: 'COMPLETE',
paymentInfo: {
payerName: '홍길동',
payerPhoneNumber: '01012345678',
means: 'CARD',
},
reservationHolderDetail: {
name: '김방문',
phoneNumber: '0212345678',
},
salesTicketType: {
id: 10,
ticketType: 'SALE',
ticketName: '일반',
price: 12000,
},
tickets: [{ ticketId: 101, csTicketId: 'T-1', createdAt: '2026-05-01T10:00:00' }],
createdAt: '2026-05-01T10:00:00',
modifiedAt: '2026-05-01T10:00:00',
};

const { container } = render(
<ReservationTable
emptyText="비어있음"
data={[row]}
selectedTicketStatus="COMPLETE"
searchText="1234"
/>,
);

expect(container.querySelector('tbody .payerPhoneNumber')?.textContent).toContain(
'010-1234-5678',
);
expect(screen.getByText('02-1234-5678')).toBeTruthy();
expect(container.querySelector('strong')?.textContent).toBe('1234');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// @vitest-environment jsdom
// 통합 테스트 목적:
// 1) iOS에서는 스킴 호출 없이 openStoreLink를 즉시 호출하는지 검증
// 2) non-iOS에서 navigateToAppScheme 실패 시 openStoreLink로 폴백하는지 검증
// 3) non-iOS에서 navigateToAppScheme 성공 시 폴백 호출을 하지 않는지 검증
import { render, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('~/utils/app', () => ({
navigateToAppScheme: vi.fn(),
}));

vi.mock('~/utils/link', () => ({
openStoreLink: vi.fn(),
}));

import AppStoreBridge from './index';
import { navigateToAppScheme } from '~/utils/app';
import { openStoreLink } from '~/utils/link';

const setUserAgent = (value: string) => {
Object.defineProperty(window.navigator, 'userAgent', {
value,
configurable: true,
});
};

describe('AppStoreBridge integration', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('iOS에서는 스토어 링크를 바로 연다', async () => {
setUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)');

render(<AppStoreBridge />);

await waitFor(() => {
expect(openStoreLink).toHaveBeenCalledTimes(1);
});
expect(navigateToAppScheme).not.toHaveBeenCalled();
});

it('비 iOS에서 스킴 호출 실패 시 스토어 링크로 폴백한다', async () => {
setUserAgent('Mozilla/5.0 (Linux; Android 14; Pixel 8)');
vi.mocked(navigateToAppScheme).mockResolvedValue(false);

render(<AppStoreBridge />);

await waitFor(() => {
expect(navigateToAppScheme).toHaveBeenCalledTimes(1);
expect(openStoreLink).toHaveBeenCalledTimes(1);
});
});

it('비 iOS에서 스킴 호출 성공 시 스토어 링크를 열지 않는다', async () => {
setUserAgent('Mozilla/5.0 (Linux; Android 14; Pixel 8)');
vi.mocked(navigateToAppScheme).mockResolvedValue(true);

render(<AppStoreBridge />);

await waitFor(() => {
expect(navigateToAppScheme).toHaveBeenCalledTimes(1);
});
expect(openStoreLink).not.toHaveBeenCalled();
});
});
89 changes: 89 additions & 0 deletions apps/admin/src/utils/app.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { navigateToAppScheme } from './app';

const setUserAgent = (value: string) => {
Object.defineProperty(window.navigator, 'userAgent', {
value,
configurable: true,
});
};

const setDocumentHidden = (value: boolean) => {
Object.defineProperty(document, 'hidden', {
value,
configurable: true,
});
};

describe('navigateToAppScheme', () => {
beforeEach(() => {
vi.useFakeTimers();
setDocumentHidden(false);
});

afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = '';
});

it('iOS에서 blur 이벤트가 발생하면 true를 반환한다', async () => {
setUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)');

const promise = navigateToAppScheme('#boolti-app');
window.dispatchEvent(new Event('blur'));

await expect(promise).resolves.toBe(true);
});

it('iOS에서 앱 전환 이벤트가 없으면 timeout 후 false를 반환한다', async () => {
setUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)');

const promise = navigateToAppScheme('#boolti-app');
vi.advanceTimersByTime(1500);

await expect(promise).resolves.toBe(false);
});

it('iOS에서 visibilitychange로 hidden 상태가 되면 true를 반환한다', async () => {
setUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)');

const promise = navigateToAppScheme('#boolti-app');
setDocumentHidden(true);
document.dispatchEvent(new Event('visibilitychange'));

await expect(promise).resolves.toBe(true);
});

it('비 iOS에서 visibilitychange로 hidden 상태가 되면 true를 반환한다', async () => {
setUserAgent('Mozilla/5.0 (Linux; Android 14; Pixel 8)');

const promise = navigateToAppScheme('#boolti-app');
setDocumentHidden(true);
document.dispatchEvent(new Event('visibilitychange'));

await expect(promise).resolves.toBe(true);
});

it('비 iOS에서 timeout 시 false를 반환하고 생성한 iframe을 제거한다', async () => {
setUserAgent('Mozilla/5.0 (Linux; Android 14; Pixel 8)');

const promise = navigateToAppScheme('#boolti-app');
expect(document.querySelectorAll('iframe').length).toBe(1);

vi.advanceTimersByTime(1000);

await expect(promise).resolves.toBe(false);
expect(document.querySelectorAll('iframe').length).toBe(0);
});

it('비 iOS에서 blur 이벤트가 발생하면 true를 반환한다', async () => {
setUserAgent('Mozilla/5.0 (Linux; Android 14; Pixel 8)');

const promise = navigateToAppScheme('#boolti-app');
window.dispatchEvent(new Event('blur'));

await expect(promise).resolves.toBe(true);
});
});
Loading
Loading