Compare commits

..

2 Commits

Author SHA1 Message Date
ttipo 6106351351 docs(rules): 시간대도 테스트 입력 — CI 는 UTC, 개발 PC 는 KST
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 20m45s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 13m40s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 13m8s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 1m57s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m20s
CI (Flutter) / Flutter (analyze · test) (push) Successful in 20m14s
CI / Backend (lint · type-check · test · e2e · build) (push) Successful in 13m42s
CI / Frontend (lint · type-check · test · build) (push) Successful in 12m31s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Successful in 1m50s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 10m48s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:19:58 +09:00
ttipo a7b9d00931 test(frontend): 앱 비밀번호 · 로그인 기록 테스트의 시각을 지역 시각으로 — CI(UTC)에서도 통과
화면은 시각을 브라우저 시간대로 보이는데, 두 테스트의 고정 시각이 UTC 문자열이라
"오늘 오전 9:12" 같은 기대 글이 KST(개발 PC)에서만 맞았다 — CI(UTC)에서는 "오늘 오전 12:12" 로 세 건 실패.
고정 시각을 지역 시각(new Date(2026, …))으로 만들고, 같은 값을 다시 적던 기대값은 고정값을 가리키게 했다.
UTC · KST · America/Los_Angeles · Pacific/Kiritimati 에서 34건 통과, 전체 2,354건 UTC 통과.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:19:57 +09:00
3 changed files with 49 additions and 33 deletions
+4
View File
@@ -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))