diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index b11ceb7..673aaeb 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -52,6 +52,9 @@ jobs: cache-dependency-path: client/package-lock.json - name: Install client deps 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 run: npm run build --prefix client diff --git a/.gitea/workflows/sonarqube.yml b/.gitea/workflows/sonarqube.yml index 1f2041b..77e0a3a 100644 --- a/.gitea/workflows/sonarqube.yml +++ b/.gitea/workflows/sonarqube.yml @@ -66,6 +66,18 @@ jobs: --test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \ 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 uses: sonarsource/sonarqube-scan-action@v4 env: diff --git a/client/package.json b/client/package.json index 72480e3..907923e 100644 --- a/client/package.json +++ b/client/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "node --test" }, "dependencies": { "@tiptap/extension-image": "^2.27.2", diff --git a/client/test/apiClient.test.js b/client/test/apiClient.test.js new file mode 100644 index 0000000..a5fda7c --- /dev/null +++ b/client/test/apiClient.test.js @@ -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('Serpent’s 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$/) +}) diff --git a/client/test/format.test.js b/client/test/format.test.js new file mode 100644 index 0000000..ddb1f5d --- /dev/null +++ b/client/test/format.test.js @@ -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 +}) diff --git a/client/test/heroLayout.test.js b/client/test/heroLayout.test.js new file mode 100644 index 0000000..9815ff8 --- /dev/null +++ b/client/test/heroLayout.test.js @@ -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') +}) diff --git a/client/test/regionBuckets.test.js b/client/test/regionBuckets.test.js new file mode 100644 index 0000000..7649a2a --- /dev/null +++ b/client/test/regionBuckets.test.js @@ -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) +}) diff --git a/client/test/shardEvents.test.js b/client/test/shardEvents.test.js new file mode 100644 index 0000000..c4e5327 --- /dev/null +++ b/client/test/shardEvents.test.js @@ -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), '') +}) diff --git a/sonar-project.properties b/sonar-project.properties index bd7b2fc..1de3fba 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -10,18 +10,19 @@ sonar.projectName=runic gateway website sonar.sources=server/src,client/src,bot/src # Test code is analysed separately from sources so coverage/metrics attribute -# correctly. Only the server has a test suite today. -sonar.tests=server/test -sonar.test.inclusions=server/test/**/*.test.js +# correctly. The server suite and the client's pure-logic unit tests both run on +# Node's built-in test runner (no browser/DOM), so they live side by side here. +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 -# test-coverage and writes an LCOV report here BEFORE the scan runs; without it -# the dashboard shows 0% (the scanner never executes tests itself). The SF: paths -# in the report are repo-root-relative (server/src/...) so the scanner resolves -# them against the project base dir. -sonar.javascript.lcov.reportPaths=server/coverage/lcov.info +# Coverage. The sonarqube.yml workflow runs both suites with Node's built-in +# test-coverage and writes an LCOV report for each BEFORE the scan runs; without +# them the dashboard shows 0% (the scanner never executes tests itself). The SF: +# paths in each report are repo-root-relative (server/src/... , client/src/...) so +# the scanner resolves them against the project base dir. +sonar.javascript.lcov.reportPaths=server/coverage/lcov.info,client/coverage/lcov.info # 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