test(client): unit-test the pure-logic modules + wire coverage into CI/Sonar
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 44s
PR Checks / server-tests (pull_request) Successful in 9m36s

Stand up a client test suite on Node's built-in runner (no vitest/jsdom — the
targeted modules are plain ESM with no browser/DOM deps) and cover the
meaningful client logic, not presentational components:

- api/client.js: the fetch wrapper — always sends the session cookie, maps a
  non-2xx response to a thrown ApiError (body.message → statusText fallback),
  resolves an empty body to null, sets Content-Type for JSON but NOT for raw
  FormData uploads, and builds/encodes query strings + path params.
- lib/shardEvents.js: describe() across event kinds (payload vs live frame,
  actor name→acct→"Someone" fallback, sale pluralization, champ.update
  branches) and the categoryOf table-consistency check.
- data/regionBuckets.js: the presence roll-up, incl. the first-match-wins
  ordering and the "buckets always reconcile to the total" invariant.
- lib/heroLayout.js: parseLayout's version/shape guard and heroBackground's
  default-vs-custom branch.
- lib/format.js: the date/label formatters + relative-time buckets.

Wiring so these actually count:
- client/package.json gains a `test` script (node --test);
- pr-checks.yml runs the client tests as a PR gate;
- sonarqube.yml generates a client LCOV report and sonar-project.properties
  feeds it alongside the server report (SF paths resolve to client/src/...).

43 client tests; coverage on the tested modules: format/regionBuckets/
heroLayout 100%, api client 86%, shardEvents 80%.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-21 00:38:53 -05:00
parent 35e5269ec5
commit 886a504152
9 changed files with 440 additions and 11 deletions

View File

@@ -52,6 +52,9 @@ jobs:
cache-dependency-path: client/package-lock.json cache-dependency-path: client/package-lock.json
- name: Install client deps - name: Install client deps
run: npm ci --prefix client run: npm ci --prefix client
- name: Run client tests
# Pure-logic unit tests on Node's built-in runner (no browser/DOM).
run: npm test --prefix client
- name: Build client - name: Build client
run: npm run build --prefix client run: npm run build --prefix client

View File

@@ -66,6 +66,18 @@ jobs:
--test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \ --test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \
server/test/*.test.js server/test/*.test.js
- name: Generate client test coverage (LCOV)
# The client's pure-logic modules (lib/, api/, data/) are plain ESM with no
# browser/DOM deps, so they run on the same built-in runner. Run from the
# repo root so the `SF:` paths come out as `client/src/...`. No `npm ci`:
# the tested modules import only relative files + Node built-ins.
run: |
mkdir -p client/coverage
node --test --experimental-test-coverage \
--test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=lcov --test-reporter-destination=client/coverage/lcov.info \
client/test/*.test.js
- name: Run SonarQube scan - name: Run SonarQube scan
uses: sonarsource/sonarqube-scan-action@v4 uses: sonarsource/sonarqube-scan-action@v4
env: env:

View File

@@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "node --test"
}, },
"dependencies": { "dependencies": {
"@tiptap/extension-image": "^2.27.2", "@tiptap/extension-image": "^2.27.2",

View File

@@ -0,0 +1,142 @@
import { test, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { api, ApiError } from '../src/api/client.js'
// Unit-test the fetch wrapper that every API call flows through. The behaviors
// that matter to the whole app:
// - it always sends the session cookie (credentials: 'include');
// - a non-2xx response becomes a thrown ApiError carrying status + a message
// (server body.message → statusText → generic), never a silent bad value;
// - an empty body resolves to null (not a JSON parse throw);
// - JSON bodies get a Content-Type, but a raw FormData upload does NOT (so the
// browser can set the multipart boundary);
// - query strings and path params are built/encoded correctly.
// We drive the real req() by mocking global.fetch and inspecting what it received.
let calls
const realFetch = global.fetch
// Build a fake Response-ish object req() understands (ok/status/statusText/text()).
function reply({ status = 200, statusText = 'OK', body = '' } = {}) {
return {
ok: status >= 200 && status < 300,
status,
statusText,
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
}
}
beforeEach(() => {
calls = []
global.fetch = async (url, opts) => {
calls.push({ url, opts })
return calls.nextReply || reply({ body: { ok: true } })
}
})
afterEach(() => {
global.fetch = realFetch
})
// helper to queue the next response
function willReply(r) {
global.fetch = async (url, opts) => {
calls.push({ url, opts })
return reply(r)
}
}
// ── happy path + cookie + base path ─────────────────────────────────────
test('a GET hits the same-origin /api/v1 base, sends cookies, and returns parsed JSON', async () => {
willReply({ body: { user: { id: 1 } } })
const out = await api.me()
assert.equal(calls[0].url, '/api/v1/auth/me')
assert.equal(calls[0].opts.credentials, 'include')
assert.equal(calls[0].opts.method, 'GET')
assert.deepEqual(out, { user: { id: 1 } })
})
// ── error mapping ───────────────────────────────────────────────────────
test('a non-ok response throws an ApiError with status and the server message', async () => {
willReply({ status: 401, statusText: 'Unauthorized', body: { message: 'Incorrect username or password.' } })
await assert.rejects(
() => api.login('u', 'bad'),
(err) => {
assert.ok(err instanceof ApiError)
assert.equal(err.status, 401)
assert.equal(err.message, 'Incorrect username or password.')
assert.deepEqual(err.body, { message: 'Incorrect username or password.' })
return true
},
)
})
test('an error with no JSON message falls back to statusText', async () => {
willReply({ status: 503, statusText: 'Service Unavailable', body: '' })
await assert.rejects(
() => api.status(),
(err) => err instanceof ApiError && err.status === 503 && err.message === 'Service Unavailable',
)
})
// ── empty body ──────────────────────────────────────────────────────────
test('an empty 200 body resolves to null instead of throwing on JSON.parse', async () => {
willReply({ status: 200, body: '' })
const out = await api.logout()
assert.equal(out, null)
})
test('a non-JSON body is returned as the raw text (safeParse tolerates it)', async () => {
willReply({ status: 200, body: 'plain text' })
const out = await api.me()
assert.equal(out, 'plain text')
})
// ── request body encoding ───────────────────────────────────────────────
test('a JSON POST serializes the body and sets Content-Type', async () => {
willReply({ body: { user: { id: 9 } } })
await api.register('newbie', 'pw', { company: '' })
const { opts } = calls[0]
assert.equal(opts.method, 'POST')
assert.equal(opts.headers['Content-Type'], 'application/json')
assert.deepEqual(JSON.parse(opts.body), { username: 'newbie', password: 'pw', company: '' })
})
test('a raw FormData upload does NOT set Content-Type and passes the body untouched', async () => {
willReply({ body: { url: '/uploads/x.png' } })
const fakeFile = { name: 'x.png' }
await api.admin.upload(fakeFile)
const { opts } = calls[0]
assert.equal(opts.method, 'POST')
assert.equal(opts.headers['Content-Type'], undefined) // browser sets the multipart boundary
assert.ok(opts.body instanceof FormData)
})
// ── query strings + path param encoding ─────────────────────────────────
test('wiki() builds a query string only from the params that are set', async () => {
willReply({ body: [] })
await api.wiki({ category: 'lore', q: 'dragon slayer' })
const url = new URL(calls[0].url, 'http://x')
assert.equal(url.pathname, '/api/v1/public/wiki')
assert.equal(url.searchParams.get('category'), 'lore')
assert.equal(url.searchParams.get('q'), 'dragon slayer')
assert.equal(url.searchParams.get('tag'), null) // omitted when unset
})
test('wiki() with no options sends no query string at all', async () => {
willReply({ body: [] })
await api.wiki()
assert.equal(calls[0].url, '/api/v1/public/wiki')
})
test('path params are URL-encoded (a token/city with unsafe characters is escaped)', async () => {
willReply({ body: {} })
await api.shard.governorHistory('Serpents Hold', 5)
assert.match(calls[0].url, /\/governors\/Serpent%E2%80%99s%20Hold\/history\?limit=5/)
})
test('DELETE self-service session revoke encodes the id and uses the DELETE method', async () => {
willReply({ body: {} })
await api.revokeMySession('a b/c')
assert.equal(calls[0].opts.method, 'DELETE')
assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/)
})

View File

@@ -0,0 +1,55 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { longDate, shortDate, dateTime, monthTile, ago, categoryLabel } from '../src/lib/format.js'
// Unit-test the shared date/label formatters. Date strings are given with an
// explicit local time (no trailing Z) so getMonth/getDate read the same value in
// any timezone the test runs in — otherwise a date-only UTC string could shift a
// day. The point is to lock the human-facing formats the whole site renders.
test('longDate renders "Month D, YYYY"', () => {
assert.equal(longDate('2026-06-24T12:00:00'), 'June 24, 2026')
})
test('shortDate renders "Mon D"', () => {
assert.equal(shortDate('2026-06-24T12:00:00'), 'Jun 24')
})
test('dateTime renders "Mon D HH:MM" with zero-padded time', () => {
assert.equal(dateTime('2026-06-24T08:05:00'), 'Jun 24 08:05')
})
test('monthTile returns the uppercased 3-letter month and 2-digit year', () => {
assert.deepEqual(monthTile('2026-06-24T12:00:00'), { mon: 'JUN', num: "'26" })
})
test('every formatter returns an empty/placeholder value for a missing or invalid date', () => {
for (const bad of [null, undefined, '', 'not-a-date']) {
assert.equal(longDate(bad), '')
assert.equal(shortDate(bad), '')
assert.equal(dateTime(bad), '')
assert.equal(ago(bad), '')
assert.deepEqual(monthTile(bad), { mon: '—', num: '' })
}
})
// ── ago(): relative-time buckets ────────────────────────────────────────
test('ago picks the right unit as the gap widens', () => {
const now = Date.now()
assert.equal(ago(new Date(now - 5 * 1000)), '5s ago')
assert.equal(ago(new Date(now - 5 * 60 * 1000)), '5m ago')
assert.equal(ago(new Date(now - 3 * 60 * 60 * 1000)), '3h ago')
assert.equal(ago(new Date(now - 2 * 24 * 60 * 60 * 1000)), '2d ago')
})
test('ago floors to at least 1s (never "0s ago" or a negative)', () => {
assert.equal(ago(new Date(Date.now())), '1s ago')
assert.equal(ago(new Date(Date.now() + 5000)), '1s ago') // a slightly-future timestamp
})
// ── categoryLabel(): known map + passthrough ────────────────────────────
test('categoryLabel maps known db categories and passes unknown ones through', () => {
assert.equal(categoryLabel('five_on_friday'), 'Five on Friday')
assert.equal(categoryLabel('newsletter'), 'Newsletter')
assert.equal(categoryLabel('mystery_category'), 'mystery_category') // unknown → itself
})

View File

@@ -0,0 +1,81 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
heroBgStack,
buildOverlay,
heroBackground,
parseLayout,
defaultLayout,
DEFAULT_HERO_IMAGE,
} from '../src/lib/heroLayout.js'
// Unit-test the hero-layout helpers shared by the public portal and the admin
// editor. The high-value logic: parseLayout must reject anything malformed or of
// the wrong version (a bad stored value must never render as a broken hero), and
// heroBackground must take the untouched-default branch only when there is no
// custom image.
// ── parseLayout: the version/shape guard ────────────────────────────────
test('parseLayout accepts a well-formed v1 layout', () => {
const layout = { version: 1, elements: [] }
assert.deepEqual(parseLayout(JSON.stringify(layout)), layout)
})
test('parseLayout returns null for missing, malformed, wrong-version, or wrong-shape input', () => {
assert.equal(parseLayout(null), null)
assert.equal(parseLayout(''), null)
assert.equal(parseLayout('{ not json'), null)
assert.equal(parseLayout(JSON.stringify({ version: 2, elements: [] })), null) // wrong version
assert.equal(parseLayout(JSON.stringify({ version: 1, elements: 'nope' })), null) // elements not an array
assert.equal(parseLayout(JSON.stringify({ version: 1 })), null) // no elements
})
// ── heroBackground: default vs custom branch ────────────────────────────
test('heroBackground uses the contained-emblem default stack only when default AND no custom image', () => {
const style = heroBackground({ background: {} }, { isDefault: true })
assert.match(style.backgroundImage, /runic-emblem\.png/)
assert.equal(style.backgroundSize, 'cover, min(74vh, 640px, 86vw)') // the two-layer default size
})
test('heroBackground threads a per-instance defaultImage into the default stack', () => {
const style = heroBackground({ background: {} }, { isDefault: true, defaultImage: '/brand/hero.jpg' })
assert.match(style.backgroundImage, /\/brand\/hero\.jpg/)
})
test('heroBackground composes overlay + custom image once a custom image is set', () => {
const style = heroBackground(
{ background: { image_url: '/uploads/hero.png', position_x: 'right', position_y: 'top', size: 'contain' }, overlay: { opacity: 0.5 } },
{ isDefault: true }, // still leaves the default branch because a custom image_url is present
)
assert.match(style.backgroundImage, /\/uploads\/hero\.png/)
assert.match(style.backgroundImage, /rgba\(11,15,20,0\.5\)/) // overlay opacity threaded in
assert.equal(style.backgroundPosition, 'right top')
assert.equal(style.backgroundSize, 'contain')
})
test('heroBackground defaults the overlay opacity to 0.72 when unset', () => {
const style = heroBackground({ background: { image_url: '/x.png' } }, {})
assert.match(style.backgroundImage, /rgba\(11,15,20,0\.72\)/)
})
// ── smaller helpers ─────────────────────────────────────────────────────
test('heroBgStack falls back to the default emblem when no image is given', () => {
assert.match(heroBgStack(), new RegExp(DEFAULT_HERO_IMAGE.replace(/[/.]/g, '\\$&')))
assert.match(heroBgStack('/custom.png'), /\/custom\.png/)
})
test('buildOverlay scales both gradient stops from the opacity', () => {
const overlay = buildOverlay(0.8)
assert.match(overlay, /rgba\(11,15,20,0.12\)/) // 0.8 * 0.15 top stop
assert.match(overlay, /rgba\(11,15,20,0.8\)/) // bottom stop
})
// ── defaultLayout: the fallback hero ────────────────────────────────────
test('defaultLayout builds a valid v1 layout that threads the teaser and shard name', () => {
const layout = defaultLayout('Come play with us', 'My Shard')
assert.equal(parseLayout(JSON.stringify(layout)) !== null, true, 'the default is itself a valid layout')
assert.equal(layout.version, 1)
const textLines = layout.elements.find((e) => e.id === 'default-text').props.lines
assert.ok(textLines.some((l) => l.text === 'My Shard'), 'shard name rendered as the h1')
assert.ok(textLines.some((l) => l.text === 'Come play with us'), 'teaser threaded in')
})

View File

@@ -0,0 +1,60 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { bucketize, BUCKETS } from '../src/data/regionBuckets.js'
// Unit-test the presence.online region roll-up for the "Players Online" widget.
// The load-bearing invariant: the bucket counts ALWAYS reconcile to the true
// total — anything unmatched lands in Wilderness — so the widget can never show
// a sum that disagrees with the headline online count.
test('bucketize groups named regions into their buckets', () => {
const { rows, total } = bucketize({
'Britain': 4,
'Moonglow': 2,
'Despise': 3,
'Green Acres House 12': 1, // not a town/dungeon name → Housing
})
const byId = Object.fromEntries(rows.map((r) => [r.id, r.count]))
assert.equal(byId.britain, 4)
assert.equal(byId.towns, 2)
assert.equal(byId.dungeons, 3)
assert.equal(byId.housing, 1)
assert.equal(total, 10)
})
test('first match wins by BUCKETS order: a town-named house region counts as Towns, not Housing', () => {
// The towns regex is ^-anchored and towns is checked BEFORE housing, so a house
// region whose name starts with a town name is bucketed as Towns. Pinning this
// documents the ordering dependency for anyone retuning BUCKETS.
const { rows } = bucketize({ 'Trinsic House 12': 1 })
const byId = Object.fromEntries(rows.map((r) => [r.id, r.count]))
assert.equal(byId.towns, 1)
assert.equal(byId.housing, undefined) // empty bucket dropped
})
test('an unmatched region falls through to Wilderness so counts always reconcile', () => {
const { rows, total } = bucketize({ 'Some Unnamed Field': 5, 'Wilderness': 2 })
const wilderness = rows.find((r) => r.id === 'wilderness')
assert.equal(wilderness.count, 7)
assert.equal(total, 7)
// The reconciliation guarantee: the buckets sum to the total, exactly.
assert.equal(rows.reduce((s, r) => s + r.count, 0), total)
})
test('bucketize returns rows in BUCKETS order and drops empty buckets', () => {
const { rows } = bucketize({ 'Despise': 1, 'Britain': 1 })
assert.deepEqual(rows.map((r) => r.id), ['britain', 'dungeons']) // BUCKETS order, no empty towns/housing/wilderness
})
test('bucketize coerces non-numeric counts and tolerates empty/nullish input', () => {
assert.deepEqual(bucketize({}), { rows: [], total: 0 })
assert.deepEqual(bucketize(), { rows: [], total: 0 })
const { total } = bucketize({ 'Britain': '3', 'Minoc': 'oops' })
assert.equal(total, 3) // '3' → 3, 'oops' → 0
})
test('the last bucket is the catch-all (its match accepts anything)', () => {
const last = BUCKETS[BUCKETS.length - 1]
assert.equal(last.id, 'wilderness')
assert.equal(last.match('literally anything'), true)
})

View File

@@ -0,0 +1,74 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { describe, categoryOf, kindLabel, CATEGORIES } from '../src/lib/shardEvents.js'
// Unit-test the shared shard-event formatter — the single place that decides how
// each event kind reads and which filter category it belongs to. These strings
// are user-facing on the public Shard page, the Activity feed, and the admin
// live feed, so a regression here is visible everywhere at once.
// ── describe(): works on both stored (.payload) and live (top-level) frames ──
test('describe reads fields from .payload when present, else the top level', () => {
const stored = { kind: 'quest.complete', payload: { who: { name: 'Ada' }, quest: 'The Cavern' } }
const live = { kind: 'quest.complete', who: { name: 'Ada' }, quest: 'The Cavern' }
assert.equal(describe(stored), 'Ada completed “The Cavern”')
assert.equal(describe(live), 'Ada completed “The Cavern”')
})
test('describe resolves an actor from name → acct → "Someone"', () => {
assert.equal(describe({ kind: 'mob.login', who: { name: 'Bob' } }), 'Bob entered the world')
assert.equal(describe({ kind: 'mob.login', who: { acct: 'acct7' } }), 'acct7 entered the world')
assert.equal(describe({ kind: 'mob.login', who: null }), 'Someone entered the world')
assert.equal(describe({ kind: 'mob.login', who: 'RawString' }), 'RawString entered the world')
})
test('describe pluralizes a vendor sale only when amount > 1 and formats the price', () => {
assert.equal(describe({ kind: 'vendor.sale', itemType: 'Katana', amount: 1, price: 1200 }), 'Katana sold for 1,200gp')
assert.equal(describe({ kind: 'vendor.sale', itemType: 'Arrow', amount: 40, price: 80 }), 'Arrow ×40 sold for 80gp')
})
test('describe includes the killer only when present (optional clause)', () => {
assert.equal(describe({ kind: 'player.death', who: { name: 'Ada' } }), 'Ada was slain')
assert.equal(
describe({ kind: 'player.death', who: { name: 'Ada' }, killer: { name: 'Orc' } }),
'Ada was slain by Orc',
)
})
test('describe champ.update branches on status and boss state', () => {
assert.equal(describe({ kind: 'champ.update', name: 'Rikktor', status: 'active', bossUp: true }), 'Rikktor: boss is up')
assert.equal(
describe({ kind: 'champ.update', name: 'Rikktor', status: 'active', level: 3 }),
'Rikktor is active — level 3',
)
assert.equal(describe({ kind: 'champ.update', name: 'Rikktor', status: 'cooldown' }), 'Rikktor is on cooldown')
})
test('describe falls back to the raw kind for an unknown event', () => {
assert.equal(describe({ kind: 'some.future.kind' }), 'some.future.kind')
})
// ── categoryOf(): membership + catch-all ────────────────────────────────
test('categoryOf groups kinds per the CATEGORIES table, and unknowns are "other"', () => {
assert.equal(categoryOf('player.death'), 'pvp')
assert.equal(categoryOf('skill.gain'), 'progress')
assert.equal(categoryOf('house.decay'), 'world')
assert.equal(categoryOf('vendor.sale'), 'other') // deliberately not a public category
assert.equal(categoryOf('totally.unknown'), 'other')
})
test('every kind listed in CATEGORIES maps back to that category (table stays consistent)', () => {
for (const cat of CATEGORIES) {
if (!cat.kinds) continue
for (const kind of cat.kinds) {
assert.equal(categoryOf(kind), cat.id, `${kind} should be in ${cat.id}`)
}
}
})
// ── kindLabel(): badge text ─────────────────────────────────────────────
test('kindLabel turns dots/underscores into spaces and tolerates empty input', () => {
assert.equal(kindLabel('player.death'), 'player death')
assert.equal(kindLabel('account.login.attempt'), 'account login attempt')
assert.equal(kindLabel(null), '')
})

View File

@@ -10,18 +10,19 @@ sonar.projectName=runic gateway website
sonar.sources=server/src,client/src,bot/src sonar.sources=server/src,client/src,bot/src
# Test code is analysed separately from sources so coverage/metrics attribute # Test code is analysed separately from sources so coverage/metrics attribute
# correctly. Only the server has a test suite today. # correctly. The server suite and the client's pure-logic unit tests both run on
sonar.tests=server/test # Node's built-in test runner (no browser/DOM), so they live side by side here.
sonar.test.inclusions=server/test/**/*.test.js sonar.tests=server/test,client/test
sonar.test.inclusions=server/test/**/*.test.js,client/test/**/*.test.js
# Coverage. The sonarqube.yml workflow runs the server suite with Node's built-in # Coverage. The sonarqube.yml workflow runs both suites with Node's built-in
# test-coverage and writes an LCOV report here BEFORE the scan runs; without it # test-coverage and writes an LCOV report for each BEFORE the scan runs; without
# the dashboard shows 0% (the scanner never executes tests itself). The SF: paths # them the dashboard shows 0% (the scanner never executes tests itself). The SF:
# in the report are repo-root-relative (server/src/...) so the scanner resolves # paths in each report are repo-root-relative (server/src/... , client/src/...) so
# them against the project base dir. # the scanner resolves them against the project base dir.
sonar.javascript.lcov.reportPaths=server/coverage/lcov.info sonar.javascript.lcov.reportPaths=server/coverage/lcov.info,client/coverage/lcov.info
# Never analyse dependencies, build output, generated specs, or runtime dirs. # Never analyse dependencies, build output, generated specs, or runtime dirs.
sonar.exclusions=**/node_modules/**,client/dist/**,client/public/**,server/swagger/**,server/logs/**,server/uploads/**,server/coverage/**,**/*.min.js sonar.exclusions=**/node_modules/**,client/dist/**,client/public/**,server/swagger/**,server/logs/**,server/uploads/**,server/coverage/**,client/coverage/**,**/*.min.js
sonar.sourceEncoding=UTF-8 sonar.sourceEncoding=UTF-8