feat(admin): view a user's shard footprint at /admin/users/:id
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
This commit is contained in:
@@ -33,6 +33,18 @@ async function countOnline() {
|
||||
const listOnline = () =>
|
||||
query(`SELECT ${ONLINE_COLS} FROM shard_online ORDER BY name ASC`)
|
||||
|
||||
// Online players on any of the given game accounts (admin: a user's linked
|
||||
// accounts). Empty list short-circuits so we never emit `IN ()`.
|
||||
const listOnlineByAccounts = (accounts) =>
|
||||
accounts.length === 0
|
||||
? Promise.resolve([])
|
||||
: query(
|
||||
`SELECT ${ONLINE_COLS} FROM shard_online
|
||||
WHERE acct IN (${accounts.map(() => '?').join(', ')})
|
||||
ORDER BY name ASC`,
|
||||
accounts,
|
||||
)
|
||||
|
||||
// Staff roles whose online presence is shown on the public Shard page. Players
|
||||
// who link an account are NOT surfaced publicly — only staff opt into visibility
|
||||
// by virtue of being staff.
|
||||
@@ -89,6 +101,18 @@ async function upsertHouse(serial, fields) {
|
||||
const listIdocHouses = () =>
|
||||
query(`SELECT ${HOUSE_COLS} FROM shard_houses WHERE is_idoc = 1 ORDER BY updated_at DESC`)
|
||||
|
||||
// Houses owned by any of the given game accounts (admin: a user's linked
|
||||
// accounts). IDOC houses first, then newest-refreshed. Empty list short-circuits.
|
||||
const listHousesByAccounts = (accounts) =>
|
||||
accounts.length === 0
|
||||
? Promise.resolve([])
|
||||
: query(
|
||||
`SELECT ${HOUSE_COLS} FROM shard_houses
|
||||
WHERE owner_acct IN (${accounts.map(() => '?').join(', ')})
|
||||
ORDER BY is_idoc DESC, updated_at DESC`,
|
||||
accounts,
|
||||
)
|
||||
|
||||
module.exports = {
|
||||
upsertOnline,
|
||||
removeOnline,
|
||||
@@ -96,9 +120,11 @@ module.exports = {
|
||||
countOnline,
|
||||
listOnline,
|
||||
listOnlineLinked,
|
||||
listOnlineByAccounts,
|
||||
insertEconomy,
|
||||
listEconomy,
|
||||
latestEconomy,
|
||||
upsertHouse,
|
||||
listIdocHouses,
|
||||
listHousesByAccounts,
|
||||
}
|
||||
|
||||
@@ -136,9 +136,8 @@ async function upsertHouse(data) {
|
||||
await db.upsertHouse(data.serial, fields)
|
||||
}
|
||||
|
||||
async function listIdoc() {
|
||||
const rows = await db.listIdocHouses()
|
||||
return rows.map((r) => ({
|
||||
function shapeHouse(r) {
|
||||
return {
|
||||
serial: r.serial,
|
||||
stage: r.stage,
|
||||
map: r.map,
|
||||
@@ -153,7 +152,24 @@ async function listIdoc() {
|
||||
lastRefreshed: r.last_refreshed,
|
||||
isIdoc: Boolean(r.is_idoc),
|
||||
updatedAt: r.updated_at,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async function listIdoc() {
|
||||
const rows = await db.listIdocHouses()
|
||||
return rows.map(shapeHouse)
|
||||
}
|
||||
|
||||
// Houses owned by the given game accounts (admin: a user's linked accounts).
|
||||
async function listHousesForAccounts(accounts) {
|
||||
const rows = await db.listHousesByAccounts(accounts)
|
||||
return rows.map(shapeHouse)
|
||||
}
|
||||
|
||||
// Online players on the given game accounts (admin: a user's linked accounts).
|
||||
async function listOnlineForAccounts(accounts) {
|
||||
const rows = await db.listOnlineByAccounts(accounts)
|
||||
return rows.map(shapeOnline)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -163,9 +179,11 @@ module.exports = {
|
||||
onlineCount,
|
||||
listOnline,
|
||||
listOnlineLinked,
|
||||
listOnlineForAccounts,
|
||||
addEconomySample,
|
||||
listEconomy,
|
||||
latestEconomy,
|
||||
upsertHouse,
|
||||
listIdoc,
|
||||
listHousesForAccounts,
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const authProviders = require('./authProviders.controller')
|
||||
const discordBot = require('./discordBot.controller')
|
||||
const emailConfig = require('./emailConfig.controller')
|
||||
const uoLink = require('./uoLink.controller')
|
||||
const usersShard = require('./usersShard.controller')
|
||||
const selfShard = require('../player/shard.controller')
|
||||
const moderation = require('./moderation.controller')
|
||||
const pagesCtrl = require('./pages.controller')
|
||||
@@ -1082,6 +1083,72 @@ adminRouter.delete(
|
||||
ctrl.deleteUser,
|
||||
)
|
||||
|
||||
// ── User → shard (uo-link) footprint (admin only) ─────────────────────
|
||||
// Backs the /admin/users/:id detail page: a user's linked game accounts and,
|
||||
// scoped to those accounts, their vendor sales / houses / online characters.
|
||||
// Live character rosters are fetched by the client through /admin/shard/* (which
|
||||
// already grants admins a bypass to any account), so no routes for them here.
|
||||
adminRouter.get(
|
||||
'/users/:id',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Get a single user (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'The user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getUser,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/users/:id/shard/accounts',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s linked game accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.listAccounts,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/users/:id/shard/sales',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getSales,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/users/:id/shard/houses',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Houses owned by a user’s accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getHouses,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/users/:id/shard/online',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s characters currently online (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getOnline,
|
||||
)
|
||||
|
||||
// ── uo-link sidecar control (admin only) ──────────────────────────────────
|
||||
// Connection config (base/ws URL + token + protocol + enabled) and the town
|
||||
// crier. The token is write-only (SECURITY note in uoLink.controller.js).
|
||||
|
||||
86
server/src/router/v1/admin/usersShard.controller.js
Normal file
86
server/src/router/v1/admin/usersShard.controller.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// ── Admin: a single user's shard (uo-link) footprint ──────────────────────────
|
||||
//
|
||||
// Backs the /admin/users/:id detail page. Every read is scoped to the target
|
||||
// user's linked game accounts (from the local shard_account_links mirror): their
|
||||
// vendor sales, houses, and currently-online characters. The live character
|
||||
// rosters are fetched separately by the client through the existing admin-bypass
|
||||
// /admin/shard/* endpoints, so nothing here round-trips the sidecar — these are
|
||||
// fast, DB-backed reads. Admin-only (registered under adminOnly in the router).
|
||||
|
||||
const users = require('../../../model/users/users.model')
|
||||
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
||||
const shardState = require('../../../model/shardState/shardState.model')
|
||||
const { salesForAccounts } = require('../../../utils/shardSales')
|
||||
|
||||
const log = require('../../../utils/logger')('admin-user-shard')
|
||||
|
||||
// Resolve the target user's linked game accounts, or null if the user id is
|
||||
// unknown (so the handler can 404 rather than silently returning an empty set).
|
||||
async function accountsForUser(id) {
|
||||
const user = await users.getById(id)
|
||||
if (!user) return null
|
||||
const links = await shardLinks.listForUser(id)
|
||||
return { user, links, accounts: links.map((l) => l.account) }
|
||||
}
|
||||
|
||||
// GET /admin/users/:id — the sanitized user (so the detail page is refresh-safe).
|
||||
async function getUser(req, res) {
|
||||
try {
|
||||
const user = await users.getById(Number(req.params.id))
|
||||
if (!user) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json(user)
|
||||
} catch (err) {
|
||||
log.error('getUser', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/users/:id/shard/accounts — the user's linked game accounts.
|
||||
async function listAccounts(req, res) {
|
||||
try {
|
||||
const ctx = await accountsForUser(Number(req.params.id))
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json(ctx.links)
|
||||
} catch (err) {
|
||||
log.error('listAccounts', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/users/:id/shard/sales — recent vendor sales on the user's accounts.
|
||||
async function getSales(req, res) {
|
||||
try {
|
||||
const ctx = await accountsForUser(Number(req.params.id))
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json(await salesForAccounts(ctx.accounts))
|
||||
} catch (err) {
|
||||
log.error('getSales', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/users/:id/shard/houses — houses owned by the user's accounts.
|
||||
async function getHouses(req, res) {
|
||||
try {
|
||||
const ctx = await accountsForUser(Number(req.params.id))
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json(await shardState.listHousesForAccounts(ctx.accounts))
|
||||
} catch (err) {
|
||||
log.error('getHouses', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/users/:id/shard/online — the user's characters currently online.
|
||||
async function getOnline(req, res) {
|
||||
try {
|
||||
const ctx = await accountsForUser(Number(req.params.id))
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json(await shardState.listOnlineForAccounts(ctx.accounts))
|
||||
} catch (err) {
|
||||
log.error('getOnline', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getUser, listAccounts, getSales, getHouses, getOnline }
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
const uoLinkClient = require('../../../utils/uoLinkClient')
|
||||
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
||||
const shardEvents = require('../../../model/shardEvents/shardEvents.model')
|
||||
const { salesForAccounts } = require('../../../utils/shardSales')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
|
||||
const log = require('../../../utils/logger')('player-shard')
|
||||
@@ -120,21 +120,8 @@ async function getChar(req, res) {
|
||||
async function getSales(req, res) {
|
||||
try {
|
||||
const links = await shardLinks.listForUser(req.user.id)
|
||||
const accounts = new Set(links.map((l) => l.account))
|
||||
if (accounts.size === 0) return res.json([])
|
||||
const events = await shardEvents.list({ kind: 'vendor.sale', limit: 500 })
|
||||
const mine = events
|
||||
.filter((e) => e.payload && accounts.has(e.payload.ownerAcct))
|
||||
.slice(0, 50)
|
||||
.map((e) => ({
|
||||
t: e.t,
|
||||
itemType: e.payload.itemType,
|
||||
amount: e.payload.amount,
|
||||
price: e.payload.price,
|
||||
commission: e.payload.commission,
|
||||
ownerAcct: e.payload.ownerAcct,
|
||||
}))
|
||||
return res.json(mine)
|
||||
const accounts = links.map((l) => l.account)
|
||||
return res.json(await salesForAccounts(accounts))
|
||||
} catch (err) {
|
||||
log.error('player.shard.getSales', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
|
||||
26
server/src/utils/shardSales.js
Normal file
26
server/src/utils/shardSales.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// Recent player-vendor sales for a set of game accounts. Shared by the player
|
||||
// self endpoint (the caller's linked accounts) and the admin user-detail
|
||||
// endpoint (a target user's linked accounts). Reads the site's own ingested
|
||||
// event log — no sidecar round-trip — and filters to sales whose owning account
|
||||
// is in the set. Newest 50, already newest-first from shardEvents.list.
|
||||
|
||||
const shardEvents = require('../model/shardEvents/shardEvents.model')
|
||||
|
||||
async function salesForAccounts(accounts) {
|
||||
const set = accounts instanceof Set ? accounts : new Set(accounts)
|
||||
if (set.size === 0) return []
|
||||
const events = await shardEvents.list({ kind: 'vendor.sale', limit: 500 })
|
||||
return events
|
||||
.filter((e) => e.payload && set.has(e.payload.ownerAcct))
|
||||
.slice(0, 50)
|
||||
.map((e) => ({
|
||||
t: e.t,
|
||||
itemType: e.payload.itemType,
|
||||
amount: e.payload.amount,
|
||||
price: e.payload.price,
|
||||
commission: e.payload.commission,
|
||||
ownerAcct: e.payload.ownerAcct,
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = { salesForAccounts }
|
||||
153
server/test/adminUserShard.test.js
Normal file
153
server/test/adminUserShard.test.js
Normal file
@@ -0,0 +1,153 @@
|
||||
// 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')
|
||||
})
|
||||
Reference in New Issue
Block a user