generated from ttipo/project-base
Compare commits
2 Commits
6c8c54d117
...
6106351351
| Author | SHA1 | Date | |
|---|---|---|---|
| 6106351351 | |||
| a7b9d00931 |
@@ -18,6 +18,10 @@ description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍,
|
||||
평소의 4~7배 — 여유가 3~6배뿐이던 한도 두 곳이 CI 를 실패시켰다). 막으려는 옛 경로(제곱 등)는 그 한도를 확실히 넘어야 한다 — 둘 사이가 좁으면 입력을 키운다.
|
||||
벽시계는 다른 작업이 CPU 를 나눠 쓰면 몇 배로 늘어 CI 에서 흔들린다 (2026-10-27: 혼자 0.8초인 정화가 부하에서 9초).
|
||||
비동기 결과를 기다릴 때도 고정 대기(`setTimeout(20)`) 대신 조건이 될 때까지 짧게 다시 보고 넉넉한 기한을 둔다.
|
||||
- **시간대도 입력이다** — CI 는 UTC, 개발 PC 는 KST 다. 화면에 보이는 시각 · 날짜 글("오늘 오전 9:12")을 글자 그대로 보는 테스트는
|
||||
시각을 지역 시각으로 만들거나(`new Date(2026, 9, 31, 9, 12)`) 포매터에 시간대를 넘긴다(frontend `format.spec.ts` 의 `SEOUL`).
|
||||
UTC 로 적은 고정값(`'2026-10-31T00:12:00Z'`)은 KST 에서만 그 글이 된다(2026-11-02 — 12단계 테스트 셋이 CI 에서만 실패).
|
||||
시각을 보이는 화면을 고쳤으면 UTC 로도 돌려 본다(Git Bash — `TZ=UTC npm run test:unit -- --run`).
|
||||
- **커밋 전 실행** — 변경 영역의 테스트가 통과해야 한다. ([[checklist]])
|
||||
- **CI 가 강제한다** — `.github/workflows/ci.yml` 이 세 스택의 lint·test·build 와 에러 코드 동기화를 PR 마다 검사한다.
|
||||
- 테스트 설명(it/test 제목)은 한국어로 "무엇을 검증하는지" 명확히 작성한다.
|
||||
|
||||
@@ -17,30 +17,36 @@ import AccountAppPasswordsPage from './AccountAppPasswordsPage.vue'
|
||||
// 설정 › 계정 › 앱 비밀번호 (12단계 — 디자인 원본 SettingsAppPasswords · 계약 5절 "앱 비밀번호")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
/** 한국 시간 2026-10-31 (토) 낮 12시 */
|
||||
const NOW = Date.parse('2026-10-31T03:00:00Z')
|
||||
/**
|
||||
* 2026년 그 시각을 이 PC 의 시간대(지역 시각)로 — 화면은 시각을 브라우저 시간대로 보인다.
|
||||
* "오늘 오전 9:12" 같은 글을 그대로 보려면 시각을 지역 시각으로 적어야 어느 시간대에서도 같다
|
||||
* (UTC 로 적었더니 개발 PC(KST)에서만 통과하고 CI(UTC)에서 실패했다 — 2026-11-02).
|
||||
*/
|
||||
const local = (month: number, day: number, hour: number, minute = 0) => new Date(2026, month - 1, day, hour, minute).toISOString()
|
||||
/** 2026-10-31 (토) 낮 12시 */
|
||||
const NOW = Date.parse(local(10, 31, 12))
|
||||
const VALUE = 'app_7qm2xk9vtn3wpl1srb3hcy7djf2gaz1euk0n'
|
||||
|
||||
const ITEMS: AppPassword[] = [
|
||||
{ id: 'c', name: '노트북 썬더버드', createdAt: '2026-10-31T00:20:00Z', lastUsed: null },
|
||||
{ id: 'c', name: '노트북 썬더버드', createdAt: local(10, 31, 9, 20), lastUsed: null },
|
||||
{
|
||||
id: 'b',
|
||||
name: '회사 PC 아웃룩',
|
||||
createdAt: '2026-10-27T00:00:00Z',
|
||||
lastUsed: { at: '2026-10-31T00:12:00Z', method: 'imap', ip: '211.186.95.252' },
|
||||
createdAt: local(10, 27, 9),
|
||||
lastUsed: { at: local(10, 31, 9, 12), method: 'imap', ip: '211.186.95.252' },
|
||||
},
|
||||
{
|
||||
id: 'a',
|
||||
name: '휴대폰 메일 앱',
|
||||
createdAt: '2026-10-20T00:00:00Z',
|
||||
lastUsed: { at: '2026-10-30T09:40:00Z', method: 'smtp', ip: '223.38.120.77' },
|
||||
createdAt: local(10, 20, 9),
|
||||
lastUsed: { at: local(10, 30, 18, 40), method: 'smtp', ip: '223.38.120.77' },
|
||||
},
|
||||
]
|
||||
|
||||
const page = (items: AppPassword[]) => ({ items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } })
|
||||
|
||||
function created(name: string): CreatedAppPassword {
|
||||
return { id: 'd', name, createdAt: '2026-10-31T02:59:00Z', lastUsed: null, password: VALUE }
|
||||
return { id: 'd', name, createdAt: local(10, 31, 11, 59), lastUsed: null, password: VALUE }
|
||||
}
|
||||
|
||||
interface Server {
|
||||
@@ -123,12 +129,12 @@ describe('AccountAppPasswordsPage', () => {
|
||||
[
|
||||
'회사 PC 아웃룩',
|
||||
formatBlockedDay(ITEMS[1]?.createdAt ?? '', nowDate),
|
||||
`${formatRelativeDayTime('2026-10-31T00:12:00Z', nowDate)} · IMAP · 211.186.95.252`,
|
||||
`${formatRelativeDayTime(ITEMS[1]?.lastUsed?.at ?? '', nowDate)} · IMAP · 211.186.95.252`,
|
||||
],
|
||||
[
|
||||
'휴대폰 메일 앱',
|
||||
formatBlockedDay(ITEMS[2]?.createdAt ?? '', nowDate),
|
||||
`${formatRelativeDayTime('2026-10-30T09:40:00Z', nowDate)} · SMTP · 223.38.120.77`,
|
||||
`${formatRelativeDayTime(ITEMS[2]?.lastUsed?.at ?? '', nowDate)} · SMTP · 223.38.120.77`,
|
||||
],
|
||||
])
|
||||
expect(rows(wrapper)[1]?.[2]).toBe('오늘 오전 9:12 · IMAP · 211.186.95.252')
|
||||
|
||||
@@ -16,30 +16,36 @@ import AccountLoginHistoryPage from './AccountLoginHistoryPage.vue'
|
||||
// 설정 › 계정 › 로그인 기록 (12단계 — 디자인 원본 SettingsLoginHistory · 계약 5절 "로그인된 브라우저" · "로그인 기록")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
/** 한국 시간 2026-10-31 (토) 낮 12시 */
|
||||
const NOW = Date.parse('2026-10-31T03:00:00Z')
|
||||
/**
|
||||
* 2026년 그 시각을 이 PC 의 시간대(지역 시각)로 — 화면은 시각을 브라우저 시간대로 보인다.
|
||||
* "오늘 오전 9:05" 같은 글을 그대로 보려면 시각을 지역 시각으로 적어야 어느 시간대에서도 같다
|
||||
* (UTC 로 적었더니 개발 PC(KST)에서만 통과하고 CI(UTC)에서 실패했다 — 2026-11-02).
|
||||
*/
|
||||
const local = (month: number, day: number, hour: number, minute = 0) => new Date(2026, month - 1, day, hour, minute).toISOString()
|
||||
/** 2026-10-31 (토) 낮 12시 */
|
||||
const NOW = Date.parse(local(10, 31, 12))
|
||||
|
||||
const SESSIONS: AuthSession[] = [
|
||||
{
|
||||
id: 'now',
|
||||
createdAt: '2026-10-31T00:00:00Z',
|
||||
lastActiveAt: '2026-10-31T02:50:00Z',
|
||||
createdAt: local(10, 31, 9),
|
||||
lastActiveAt: local(10, 31, 11, 50),
|
||||
ip: '211.186.95.252',
|
||||
device: { browser: 'Chrome', os: 'Windows' },
|
||||
isCurrent: true,
|
||||
},
|
||||
{
|
||||
id: 'edge',
|
||||
createdAt: '2026-10-30T00:00:00Z',
|
||||
lastActiveAt: '2026-10-31T00:05:00Z',
|
||||
createdAt: local(10, 30, 9),
|
||||
lastActiveAt: local(10, 31, 9, 5),
|
||||
ip: '211.186.95.252',
|
||||
device: { browser: 'Edge', os: 'Windows' },
|
||||
isCurrent: false,
|
||||
},
|
||||
{
|
||||
id: 'phone',
|
||||
createdAt: '2026-10-29T00:00:00Z',
|
||||
lastActiveAt: '2026-10-30T13:31:00Z',
|
||||
createdAt: local(10, 29, 9),
|
||||
lastActiveAt: local(10, 30, 22, 31),
|
||||
ip: null,
|
||||
device: { browser: 'Safari', os: 'iOS' },
|
||||
isCurrent: false,
|
||||
@@ -49,7 +55,7 @@ const SESSIONS: AuthSession[] = [
|
||||
function loginEvent(id: string, changes: Partial<LoginEvent>): LoginEvent {
|
||||
return {
|
||||
id,
|
||||
occurredAt: '2026-10-31T00:30:00Z',
|
||||
occurredAt: local(10, 31, 9, 30),
|
||||
method: 'webmail',
|
||||
isSuccess: true,
|
||||
isTwoFactor: false,
|
||||
@@ -62,23 +68,23 @@ function loginEvent(id: string, changes: Partial<LoginEvent>): LoginEvent {
|
||||
}
|
||||
|
||||
const EVENTS: LoginEvent[] = [
|
||||
loginEvent('e1', { occurredAt: '2026-10-31T00:30:00Z', isTwoFactor: true }),
|
||||
loginEvent('e2', { occurredAt: '2026-10-31T00:29:00Z', isSuccess: false, failureReason: 'wrongCode' }),
|
||||
loginEvent('e1', { occurredAt: local(10, 31, 9, 30), isTwoFactor: true }),
|
||||
loginEvent('e2', { occurredAt: local(10, 31, 9, 29), isSuccess: false, failureReason: 'wrongCode' }),
|
||||
loginEvent('e3', {
|
||||
occurredAt: '2026-10-31T00:12:00Z',
|
||||
occurredAt: local(10, 31, 9, 12),
|
||||
method: 'imap',
|
||||
device: null,
|
||||
appPassword: { id: 'b', name: '회사 PC 아웃룩' },
|
||||
}),
|
||||
loginEvent('e4', {
|
||||
occurredAt: '2026-10-30T14:58:00Z',
|
||||
occurredAt: local(10, 30, 23, 58),
|
||||
method: 'smtp',
|
||||
device: null,
|
||||
isSuccess: false,
|
||||
failureReason: 'appPasswordRequired',
|
||||
ip: '45.133.10.21',
|
||||
}),
|
||||
loginEvent('e5', { occurredAt: '2026-10-29T06:21:00Z', device: null, isSuccess: false, failureReason: 'wrongPassword' }),
|
||||
loginEvent('e5', { occurredAt: local(10, 29, 15, 21), device: null, isSuccess: false, failureReason: 'wrongPassword' }),
|
||||
]
|
||||
|
||||
const pageOf = <T>(items: T[], next: string | null = null, total: number | null = items.length) => ({
|
||||
@@ -175,8 +181,8 @@ describe('AccountLoginHistoryPage', () => {
|
||||
])
|
||||
expect(sessionRows(wrapper)).toEqual([
|
||||
['Chrome · Windows', '211.186.95.252', '지금 이 브라우저'],
|
||||
['Edge · Windows', '211.186.95.252', formatRelativeDayTime('2026-10-31T00:05:00Z', nowDate)],
|
||||
['Safari · iOS', '—알 수 없음', formatRelativeDayTime('2026-10-30T13:31:00Z', nowDate)],
|
||||
['Edge · Windows', '211.186.95.252', formatRelativeDayTime(SESSIONS[1]?.lastActiveAt ?? '', nowDate)],
|
||||
['Safari · iOS', '—알 수 없음', formatRelativeDayTime(SESSIONS[2]?.lastActiveAt ?? '', nowDate)],
|
||||
])
|
||||
expect(sessionRows(wrapper)[1]?.[2]).toBe('오늘 오전 9:05')
|
||||
expect(wrapper.findAll('[data-session] svg').map((icon) => icon.attributes('aria-label'))).toEqual(['PC', 'PC', '휴대폰'])
|
||||
@@ -319,15 +325,15 @@ describe('AccountLoginHistoryPage', () => {
|
||||
it('시각 · 방식(앱 비밀번호 이름) · IP · 기기(메일 프로그램은 —) · 결과 — 실패는 빨간 굵은 글자만', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const nowDate = new Date(NOW)
|
||||
const time = (value: string) => formatRelativeDayTime(value, nowDate)
|
||||
const time = (index: number) => formatRelativeDayTime(EVENTS[index]?.occurredAt ?? '', nowDate)
|
||||
|
||||
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 웹메일과 메일 프로그램')
|
||||
expect(eventRows(wrapper)).toEqual([
|
||||
[time('2026-10-31T00:30:00Z'), '웹메일', '211.186.95.252', 'Chrome · Windows', '성공 · 2단계 인증'],
|
||||
[time('2026-10-31T00:29:00Z'), '웹메일', '211.186.95.252', 'Chrome · Windows', '실패 — 코드 틀림'],
|
||||
[time('2026-10-31T00:12:00Z'), '메일 프로그램 · IMAP앱 비밀번호 ‘회사 PC 아웃룩’', '211.186.95.252', '—알 수 없음', '성공'],
|
||||
[time('2026-10-30T14:58:00Z'), '메일 프로그램 · SMTP', '45.133.10.21', '—알 수 없음', '실패 — 앱 비밀번호 필요'],
|
||||
[time('2026-10-29T06:21:00Z'), '웹메일', '211.186.95.252', '알 수 없는 브라우저', '실패 — 비밀번호 틀림'],
|
||||
[time(0), '웹메일', '211.186.95.252', 'Chrome · Windows', '성공 · 2단계 인증'],
|
||||
[time(1), '웹메일', '211.186.95.252', 'Chrome · Windows', '실패 — 코드 틀림'],
|
||||
[time(2), '메일 프로그램 · IMAP앱 비밀번호 ‘회사 PC 아웃룩’', '211.186.95.252', '—알 수 없음', '성공'],
|
||||
[time(3), '메일 프로그램 · SMTP', '45.133.10.21', '—알 수 없음', '실패 — 앱 비밀번호 필요'],
|
||||
[time(4), '웹메일', '211.186.95.252', '알 수 없는 브라우저', '실패 — 비밀번호 틀림'],
|
||||
])
|
||||
expect(eventRows(wrapper)[0]?.[0]).toBe('오늘 오전 9:30')
|
||||
expect(wrapper.findAll('[data-event-result]').map((cell) => cell.classes().includes('events__result--failure'))).toEqual([
|
||||
@@ -383,7 +389,7 @@ describe('AccountLoginHistoryPage', () => {
|
||||
|
||||
it('[더 보기] — 커서로 20개씩 이어 받는다(실패만이면 실패만) · 다 받으면 단추가 사라진다', async () => {
|
||||
const failures = EVENTS.filter((event) => !event.isSuccess)
|
||||
const more = [loginEvent('e9', { occurredAt: '2026-10-12T19:02:00Z', isSuccess: false, failureReason: 'wrongPassword' })]
|
||||
const more = [loginEvent('e9', { occurredAt: local(10, 13, 4, 2), isSuccess: false, failureReason: 'wrongPassword' })]
|
||||
const wrapper = await mountPage({
|
||||
events: (request) => {
|
||||
if (request.params.cursor === 'next-failures') return ok(pageOf(more, null, 4))
|
||||
|
||||
Reference in New Issue
Block a user