Add a "View" action beside Edit in the users table that opens a dedicated,
read-only page showing everything the uo-link shard knows about a user,
scoped to their linked game accounts: character rosters, currently-online
characters, houses (IDOC-first), and recent vendor sales.
Backend (admin-only, under the existing /users adminOnly gate):
- GET /admin/users/:id — single sanitized user (page is deep-linkable)
- GET /admin/users/:id/shard/{accounts,sales,houses,online}
- shardState: listHousesByAccounts / listOnlineByAccounts (+ model shapers)
- Extract salesForAccounts into utils/shardSales; reuse in player getSales
- Live rosters reuse the existing admin-bypass /admin/shard/* endpoints,
so no new routes for roster/vendors/char
Frontend:
- UserDetail page reusing CharacterStats / GameAccounts / VendorSales
- GameAccounts gains a readOnly prop (drops link form + self-voice copy)
- api.admin.getUser + api.admin.userShard(id) scope; route + layout title
Tests: adminUserShard.test.js (404, account scoping, empty accounts,
salesForAccounts cap/filter). Full server suite 164 pass; client builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
154 lines
6.0 KiB
JavaScript
154 lines
6.0 KiB
JavaScript
// Point the DB at a closed port BEFORE requiring anything that builds the pool,
|
||
// so any stray query fails fast instead of hanging the runner. These tests stub
|
||
// every model method the controller touches, so the DB is never actually hit.
|
||
process.env.DB_HOST = '127.0.0.1'
|
||
process.env.DB_PORT = '59999'
|
||
|
||
const { test, after, afterEach } = require('node:test')
|
||
const assert = require('node:assert/strict')
|
||
|
||
const ctrl = require('../src/router/v1/admin/usersShard.controller')
|
||
const users = require('../src/model/users/users.model')
|
||
const shardLinks = require('../src/model/shardLinks/shardLinks.model')
|
||
const shardState = require('../src/model/shardState/shardState.model')
|
||
const shardEvents = require('../src/model/shardEvents/shardEvents.model')
|
||
const { salesForAccounts } = require('../src/utils/shardSales')
|
||
const db = require('../src/utils/db')
|
||
|
||
after(() => db.close())
|
||
|
||
function mockRes() {
|
||
return {
|
||
statusCode: 200,
|
||
body: null,
|
||
status(c) {
|
||
this.statusCode = c
|
||
return this
|
||
},
|
||
json(b) {
|
||
this.body = b
|
||
return this
|
||
},
|
||
}
|
||
}
|
||
|
||
// Save/restore the originals so each test's monkeypatches don't leak.
|
||
const originals = {
|
||
getById: users.getById,
|
||
listForUser: shardLinks.listForUser,
|
||
listHousesForAccounts: shardState.listHousesForAccounts,
|
||
listOnlineForAccounts: shardState.listOnlineForAccounts,
|
||
eventsList: shardEvents.list,
|
||
}
|
||
afterEach(() => {
|
||
users.getById = originals.getById
|
||
shardLinks.listForUser = originals.listForUser
|
||
shardState.listHousesForAccounts = originals.listHousesForAccounts
|
||
shardState.listOnlineForAccounts = originals.listOnlineForAccounts
|
||
shardEvents.list = originals.eventsList
|
||
})
|
||
|
||
// ── salesForAccounts util ──────────────────────────────────────────────────
|
||
test('salesForAccounts returns [] for an empty account set without hitting the log', async () => {
|
||
let called = false
|
||
shardEvents.list = async () => {
|
||
called = true
|
||
return []
|
||
}
|
||
assert.deepEqual(await salesForAccounts([]), [])
|
||
assert.equal(called, false)
|
||
})
|
||
|
||
test('salesForAccounts keeps only sales owned by the given accounts, newest 50', async () => {
|
||
const events = []
|
||
// 60 sales owned by "mine", plus some owned by "other".
|
||
for (let i = 0; i < 60; i++) {
|
||
events.push({ t: i, payload: { ownerAcct: 'mine', itemType: 'sword', amount: 1, price: 10, commission: 1 } })
|
||
}
|
||
events.push({ t: 999, payload: { ownerAcct: 'other', itemType: 'shield', amount: 1, price: 5 } })
|
||
shardEvents.list = async () => events
|
||
|
||
const rows = await salesForAccounts(['mine'])
|
||
assert.equal(rows.length, 50) // capped
|
||
assert.ok(rows.every((r) => r.ownerAcct === 'mine')) // never leaks "other"
|
||
assert.deepEqual(Object.keys(rows[0]).sort(), ['amount', 'commission', 'itemType', 'ownerAcct', 'price', 't'])
|
||
})
|
||
|
||
// ── Controller: unknown user → 404 ─────────────────────────────────────────
|
||
for (const handler of ['getUser', 'listAccounts', 'getSales', 'getHouses', 'getOnline']) {
|
||
test(`${handler} returns 404 when the user does not exist`, async () => {
|
||
users.getById = async () => null
|
||
const res = mockRes()
|
||
await ctrl[handler]({ params: { id: '404' } }, res)
|
||
assert.equal(res.statusCode, 404)
|
||
})
|
||
}
|
||
|
||
// ── Controller: scoping to the user's accounts ─────────────────────────────
|
||
test('listAccounts returns the user’s linked accounts', async () => {
|
||
users.getById = async () => ({ id: 7, username: 'bob', role: 'player' })
|
||
shardLinks.listForUser = async (id) => {
|
||
assert.equal(id, 7)
|
||
return [{ account: 'acctA' }, { account: 'acctB' }]
|
||
}
|
||
const res = mockRes()
|
||
await ctrl.listAccounts({ params: { id: '7' } }, res)
|
||
assert.equal(res.statusCode, 200)
|
||
assert.deepEqual(res.body, [{ account: 'acctA' }, { account: 'acctB' }])
|
||
})
|
||
|
||
test('getHouses passes exactly the user’s accounts to the model', async () => {
|
||
users.getById = async () => ({ id: 7 })
|
||
shardLinks.listForUser = async () => [{ account: 'acctA' }, { account: 'acctB' }]
|
||
let received = null
|
||
shardState.listHousesForAccounts = async (accounts) => {
|
||
received = accounts
|
||
return [{ serial: '0x1', isIdoc: true }]
|
||
}
|
||
const res = mockRes()
|
||
await ctrl.getHouses({ params: { id: '7' } }, res)
|
||
assert.deepEqual(received, ['acctA', 'acctB'])
|
||
assert.deepEqual(res.body, [{ serial: '0x1', isIdoc: true }])
|
||
})
|
||
|
||
test('getOnline passes exactly the user’s accounts to the model', async () => {
|
||
users.getById = async () => ({ id: 7 })
|
||
shardLinks.listForUser = async () => [{ account: 'acctA' }]
|
||
let received = null
|
||
shardState.listOnlineForAccounts = async (accounts) => {
|
||
received = accounts
|
||
return [{ serial: '0x2', name: 'Zoe' }]
|
||
}
|
||
const res = mockRes()
|
||
await ctrl.getOnline({ params: { id: '7' } }, res)
|
||
assert.deepEqual(received, ['acctA'])
|
||
assert.deepEqual(res.body, [{ serial: '0x2', name: 'Zoe' }])
|
||
})
|
||
|
||
test('a user with no linked accounts yields empty sales/houses/online', async () => {
|
||
users.getById = async () => ({ id: 7 })
|
||
shardLinks.listForUser = async () => []
|
||
shardState.listHousesForAccounts = async (a) => (a.length ? [{}] : [])
|
||
shardState.listOnlineForAccounts = async (a) => (a.length ? [{}] : [])
|
||
shardEvents.list = async () => [{ payload: { ownerAcct: 'someoneElse' } }]
|
||
|
||
const sales = mockRes()
|
||
const houses = mockRes()
|
||
const online = mockRes()
|
||
await ctrl.getSales({ params: { id: '7' } }, sales)
|
||
await ctrl.getHouses({ params: { id: '7' } }, houses)
|
||
await ctrl.getOnline({ params: { id: '7' } }, online)
|
||
|
||
assert.deepEqual(sales.body, [])
|
||
assert.deepEqual(houses.body, [])
|
||
assert.deepEqual(online.body, [])
|
||
})
|
||
|
||
test('getUser returns the sanitized user row', async () => {
|
||
users.getById = async () => ({ id: 7, username: 'bob', role: 'player', status: 'active' })
|
||
const res = mockRes()
|
||
await ctrl.getUser({ params: { id: '7' } }, res)
|
||
assert.equal(res.statusCode, 200)
|
||
assert.equal(res.body.username, 'bob')
|
||
})
|