v1.12.30 긴 접속 시간 표시와 중단 후 새 방문 처리 개선
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { formatAnalyticsCount, getActivePageHref, getActivePageTitle, getRealtimePages } from '../lib/analytics-display.js'
|
||||
import { formatAnalyticsCount, formatRealtimeDuration, getActivePageHref, getActivePageTitle, getRealtimePages } from '../lib/analytics-display.js'
|
||||
|
||||
test('통계 수치는 천 단위 쉼표와 빈 값 보정을 제공한다', () => {
|
||||
assert.equal(formatAnalyticsCount(10413), '10,413')
|
||||
@@ -21,6 +21,17 @@ test('실시간 페이지는 최근 확인순으로 같은 경로를 중복 제
|
||||
assert.equal(getRealtimePages({ path: '/', pages: [] }).length, 1)
|
||||
})
|
||||
|
||||
test('긴 접속 시간은 시간·일 단위로 짧게 표시한다', () => {
|
||||
assert.equal(formatRealtimeDuration(59), '59초')
|
||||
assert.equal(formatRealtimeDuration(1861), '31분 1초')
|
||||
assert.equal(formatRealtimeDuration(3600), '1시간')
|
||||
assert.equal(formatRealtimeDuration(4920), '1시간 22분')
|
||||
assert.equal(formatRealtimeDuration(172800), '2일')
|
||||
assert.equal(formatRealtimeDuration(176400), '2일 1시간')
|
||||
assert.equal(formatRealtimeDuration(-1), '0초')
|
||||
assert.equal(formatRealtimeDuration(Infinity), '0초')
|
||||
})
|
||||
|
||||
test('실시간 바로가기는 공개 내부 경로만 사용한다', () => {
|
||||
assert.equal(getActivePageHref({ postSlug: '한글 제목' }), '/post/%ED%95%9C%EA%B8%80%20%EC%A0%9C%EB%AA%A9')
|
||||
assert.equal(getActivePageHref({ path: '/' }), '/')
|
||||
|
||||
48
tests/analytics-duration.test.js
Normal file
48
tests/analytics-duration.test.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createAnalyticsDurationTracker } from '../lib/analytics-duration.js'
|
||||
import { ANALYTICS_MAX_REALTIME_DURATION_SECONDS, clampAnalyticsDurationSeconds, clampAnalyticsRealtimeDurationSeconds } from '../lib/analytics-shared.js'
|
||||
|
||||
test('접속 신호가 이어지면 30분을 넘긴 시간도 누적한다', () => {
|
||||
const sample = createAnalyticsDurationTracker(0)
|
||||
for (let now = 20000; now <= 7200000; now += 20000) sample(now)
|
||||
assert.deepEqual(sample(7200000), { durationSeconds: 7200, restarted: false })
|
||||
})
|
||||
|
||||
test('30분 이상 실행이 중단되면 복귀 시 새 방문으로 시작한다', () => {
|
||||
const sample = createAnalyticsDurationTracker(0)
|
||||
assert.equal(sample(20000).durationSeconds, 20)
|
||||
assert.equal(sample(40000).durationSeconds, 40)
|
||||
const resumedAt = 40000 + 3 * 86400000
|
||||
assert.deepEqual(sample(resumedAt), { durationSeconds: 0, restarted: true })
|
||||
assert.deepEqual(sample(resumedAt + 20000), { durationSeconds: 20, restarted: false })
|
||||
assert.equal(sample(resumedAt + 110000).durationSeconds, 20)
|
||||
assert.equal(sample(resumedAt + 130000).durationSeconds, 40)
|
||||
assert.deepEqual(sample(resumedAt + 1930000), { durationSeconds: 0, restarted: true })
|
||||
})
|
||||
|
||||
test('90초 이상 30분 미만 중단은 시간만 제외하고 기존 방문을 유지한다', () => {
|
||||
const sample = createAnalyticsDurationTracker(0)
|
||||
assert.equal(sample(20000).durationSeconds, 20)
|
||||
assert.deepEqual(sample(110000), { durationSeconds: 20, restarted: false })
|
||||
assert.deepEqual(sample(1909000), { durationSeconds: 20, restarted: false })
|
||||
assert.deepEqual(sample(1929000), { durationSeconds: 40, restarted: false })
|
||||
})
|
||||
|
||||
test('짧은 지연과 소수 초를 보존하고 시계 역행·잘못된 시각은 누적하지 않는다', () => {
|
||||
const sample = createAnalyticsDurationTracker(1000)
|
||||
assert.equal(sample(1500).durationSeconds, 0)
|
||||
assert.equal(sample(2000).durationSeconds, 1)
|
||||
assert.equal(sample(82000).durationSeconds, 81)
|
||||
assert.equal(sample(81000).durationSeconds, 81)
|
||||
assert.equal(sample(NaN).durationSeconds, 81)
|
||||
assert.equal(sample(101000).durationSeconds, 101)
|
||||
assert.equal(createAnalyticsDurationTracker(101000)(101000).durationSeconds, 0)
|
||||
})
|
||||
|
||||
test('실시간 접속 시간은 정수 범위까지 허용하고 평균 체류 통계 상한은 유지한다', () => {
|
||||
assert.equal(clampAnalyticsRealtimeDurationSeconds(7200.9), 7200)
|
||||
assert.equal(clampAnalyticsDurationSeconds(7200), 1800)
|
||||
assert.equal(clampAnalyticsRealtimeDurationSeconds(Number.MAX_SAFE_INTEGER), ANALYTICS_MAX_REALTIME_DURATION_SECONDS)
|
||||
for (const value of [-1, NaN, Infinity]) assert.equal(clampAnalyticsRealtimeDurationSeconds(value), 0)
|
||||
})
|
||||
@@ -428,6 +428,42 @@ test('실시간 접속 요약은 표시 제한 밖의 활성 탭까지 집계하
|
||||
}
|
||||
})
|
||||
|
||||
test('긴 접속 시간은 실시간에 표시하고 평균 체류 집계 상한을 유지한다', async ({ request: anonymousApi }) => {
|
||||
const slug = 'realtime-duration-api-test'
|
||||
const [post] = await database`
|
||||
INSERT INTO posts (title, slug, content, status, author_id, published_at)
|
||||
VALUES ('긴 접속 시간 검증', ${slug}, '시간 검증', 'published', ${adminUser.userId}, now())
|
||||
RETURNING id
|
||||
`
|
||||
const heartbeat = {
|
||||
path: `/post/${slug}`, postSlug: slug, clientSessionId: 'duration-api-client-session',
|
||||
contentSessionId: 'duration-api-content-session', durationSeconds: 1810, maxScrollRatio: 0
|
||||
}
|
||||
try {
|
||||
expect((await anonymousApi.post('/api/analytics/heartbeat', { data: heartbeat })).status()).toBe(200)
|
||||
heartbeat.durationSeconds = 7200
|
||||
expect((await anonymousApi.post('/api/analytics/heartbeat', { data: heartbeat })).status()).toBe(200)
|
||||
const realtime = await adminApi.get('/admin/api/analytics/realtime')
|
||||
expect((await realtime.json()).sessions.find((session) => session.postSlug === slug)?.durationSeconds).toBe(7200)
|
||||
const sessions = await database`SELECT duration_seconds FROM analytics_content_sessions WHERE post_id = ${post.id}`
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0].duration_seconds).toBe(1800)
|
||||
const daily = await database`SELECT total_engaged_seconds FROM post_analytics_daily WHERE post_id = ${post.id}`
|
||||
expect(daily[0].total_engaged_seconds).toBe(1800)
|
||||
heartbeat.durationSeconds = 2147483648
|
||||
expect((await anonymousApi.post('/api/analytics/heartbeat', { data: heartbeat })).status()).toBe(400)
|
||||
heartbeat.contentSessionId = 'duration-api-return-visit'
|
||||
heartbeat.durationSeconds = 0
|
||||
expect((await anonymousApi.post('/api/analytics/heartbeat', { data: heartbeat })).status()).toBe(200)
|
||||
const restarted = await adminApi.get('/admin/api/analytics/realtime')
|
||||
expect((await restarted.json()).sessions.find((session) => session.postSlug === slug)?.durationSeconds).toBe(0)
|
||||
} finally {
|
||||
await database`DELETE FROM analytics_active_sessions WHERE post_slug = ${slug}`
|
||||
await database`DELETE FROM analytics_content_sessions WHERE post_id = ${post.id}`
|
||||
await database`DELETE FROM posts WHERE id = ${post.id}`
|
||||
}
|
||||
})
|
||||
|
||||
test('미디어 카탈로그 reconcile과 DB 목록 계약 및 fallback을 유지한다', async () => {
|
||||
const uploadRoot = process.env.SORI_TEST_UPLOAD_ROOT
|
||||
const mediaDirectory = join(uploadRoot, 'posts', 'catalog-api-test')
|
||||
|
||||
@@ -16,7 +16,7 @@ test('대시보드 숫자·섹션 순서와 한 줄 접속자 페이지 목록
|
||||
sources: [], devices: [], topPosts: [], topPages: []
|
||||
} }))
|
||||
const session = {
|
||||
activeKey: 'test-visitor', isLoggedIn: false, ipAddress: '198.51.100.1', durationSeconds: 1800, pageCount: 6,
|
||||
activeKey: 'test-visitor', isLoggedIn: false, ipAddress: '198.51.100.1', durationSeconds: 4920, pageCount: 6,
|
||||
pages: [
|
||||
{ key: 'old', path: '/post/old', postTitle: '이전 페이지', lastSeenAt: '2026-10-21T01:00:00Z' },
|
||||
{ key: 'new', path: '/post/new', postTitle: '가장 최근 페이지', lastSeenAt: '2026-10-21T06:00:00Z' },
|
||||
@@ -42,7 +42,8 @@ test('대시보드 숫자·섹션 순서와 한 줄 접속자 페이지 목록
|
||||
await expect(visitor.locator('.admin-realtime-pages__extra')).toHaveCSS('color', 'rgb(37, 99, 235)')
|
||||
await expect(visitor.locator('.admin-realtime-pages__extra-number')).toHaveText('4')
|
||||
await expect(visitor.locator('.admin-realtime-pages__extra-number')).toHaveCSS('font-weight', '700')
|
||||
await expect(visitor.locator('.admin-dashboard__live-duration')).toHaveAttribute('title', /백그라운드 시간을 포함하며 최대 30분/)
|
||||
await expect(visitor.locator('.admin-dashboard__live-duration')).toHaveText('1시간 22분')
|
||||
await expect(visitor.locator('.admin-dashboard__live-duration')).toHaveAttribute('title', /30분 이상 중단되면 새 방문으로 시작/)
|
||||
expect((await visitor.boundingBox()).height).toBeLessThan(45)
|
||||
const trigger = visitor.getByRole('button', { name: '열린 페이지 목록', exact: true })
|
||||
await trigger.hover()
|
||||
@@ -65,3 +66,58 @@ test('대시보드 숫자·섹션 순서와 한 줄 접속자 페이지 목록
|
||||
await expect(singleVisitor.getByRole('link', { name: '한 페이지만 보는 방문자' })).toHaveAttribute('href', '/post/single')
|
||||
await expect(popover).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('공개 페이지는 짧은 실행 중단을 제외하고 긴 중단 후 새 방문으로 시작한다', async ({ page }) => {
|
||||
const login = await page.request.post('/admin/api/auth/login', {
|
||||
data: { email: 'playwright-admin@example.com', password: 'playwright-admin-password' }
|
||||
})
|
||||
expect(login.ok()).toBeTruthy()
|
||||
const created = await page.request.post('/admin/api/posts', {
|
||||
data: { title: '접속 시간 검증', slug: 'duration-ui-test', content: '시간 측정 본문', status: 'published', tags: [], tagNames: {} }
|
||||
})
|
||||
expect(created.ok()).toBeTruthy()
|
||||
const post = await created.json()
|
||||
try {
|
||||
const heartbeats = []
|
||||
const pageviews = []
|
||||
await page.clock.install({ time: new Date('2026-10-21T00:00:00Z') })
|
||||
await page.route('**/api/analytics/heartbeat', async (route) => {
|
||||
heartbeats.push(route.request().postDataJSON())
|
||||
await route.fulfill({ json: { ok: true } })
|
||||
})
|
||||
await page.route('**/api/analytics/pageview', async (route) => {
|
||||
pageviews.push(route.request().postDataJSON())
|
||||
await route.fulfill({ json: { ok: true } })
|
||||
})
|
||||
await page.goto('/post/duration-ui-test')
|
||||
await expect.poll(() => heartbeats.length).toBeGreaterThan(0)
|
||||
await page.clock.pauseAt(new Date('2026-10-21T00:01:00Z'))
|
||||
await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight))
|
||||
await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange')))
|
||||
await expect.poll(() => heartbeats.at(-1)?.durationSeconds).toBeGreaterThan(0)
|
||||
const initialDuration = heartbeats.at(-1).durationSeconds
|
||||
await page.clock.runFor(40000)
|
||||
await expect.poll(() => heartbeats.at(-1)?.durationSeconds).toBe(initialDuration + 40)
|
||||
const initialContentSession = heartbeats.at(-1).contentSessionId
|
||||
const beforeSleepCount = heartbeats.length
|
||||
await page.clock.fastForward(120000)
|
||||
await expect.poll(() => heartbeats.length).toBeGreaterThan(beforeSleepCount)
|
||||
expect(heartbeats.at(-1).durationSeconds).toBe(initialDuration + 40)
|
||||
expect(heartbeats.at(-1).contentSessionId).toBe(initialContentSession)
|
||||
await page.clock.runFor(20000)
|
||||
await expect.poll(() => heartbeats.at(-1)?.durationSeconds).toBe(initialDuration + 60)
|
||||
const beforeRestartViews = pageviews.filter((entry) => !entry.read).length
|
||||
await page.clock.fastForward(2 * 86400000)
|
||||
await expect.poll(() => heartbeats.at(-1)?.durationSeconds).toBe(0)
|
||||
expect(heartbeats.at(-1).contentSessionId).not.toBe(initialContentSession)
|
||||
await expect.poll(() => pageviews.filter((entry) => !entry.read).length).toBe(beforeRestartViews + 1)
|
||||
expect(pageviews.at(-1).contentSessionId).toBe(heartbeats.at(-1).contentSessionId)
|
||||
const restartedContentSession = heartbeats.at(-1).contentSessionId
|
||||
expect(pageviews.filter((entry) => entry.read && entry.contentSessionId === restartedContentSession)).toHaveLength(0)
|
||||
await page.clock.runFor(20000)
|
||||
await expect.poll(() => heartbeats.at(-1)?.durationSeconds).toBe(20)
|
||||
await expect.poll(() => pageviews.filter((entry) => entry.read && entry.contentSessionId === restartedContentSession).length).toBe(1)
|
||||
} finally {
|
||||
await page.request.delete(`/admin/api/posts/${post.id}`)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user