// Point the DB at a closed port BEFORE requiring anything that builds a pool. // Nothing here reaches the database: these are the model's PURE parts — the // flatten/shape rules the frame passes through on the way in and out — plus the // visibility projection over the shapes they produce. process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' const { test, after } = require('node:test') const assert = require('node:assert/strict') const market = require('../src/model/shardMarket/shardMarket.model') const clilocs = require('../src/model/shardClilocs/shardClilocs.model') const clilocDb = require('../src/model/shardClilocs/shardClilocs.db') const visibility = require('../src/utils/shardVisibility') const db = require('../src/utils/db') after(() => db.close()) // Stand in for the cliloc table. Without this each unresolved lookup waits out // the pool's 10s acquire timeout against the dead port — the model swallows the // failure exactly as it would in production (an operator who never converted a // cliloc file is in a supported state), so the RESULT is the same either way; // this only stops the suite spending half a minute proving it. const TABLE = new Map([[1023721, 'quarter staff']]) clilocDb.lookup = async (numbers) => numbers.filter((n) => TABLE.has(n)).map((n) => ({ number: n, text: TABLE.get(n) })) const FRAME = { kind: 'vendor.listing', t: 1000, serial: '0x40001234', shopName: "Darrow's Bargains", ownerSerial: '0x1A2B', ownerName: 'Darrow', location: { map: 'Trammel', x: 1421, y: 1699, z: 0, region: 'Britain', house: "Darrow's Villa" }, count: 2, total: 2, truncated: false, items: [], } // ── flattenFrame ─────────────────────────────────────────────────────────── test('flattenFrame lifts the nested location into columns', () => { const v = market.flattenFrame(FRAME) assert.equal(v.serial, '0x40001234') assert.equal(v.map, 'Trammel') assert.equal(v.x, 1421) assert.equal(v.region, 'Britain') assert.equal(v.house, "Darrow's Villa") }) // A vendor standing in the street has no house, and a frame from an older plugin // may have no location at all. Neither is an error. test('flattenFrame tolerates a missing location entirely', () => { const v = market.flattenFrame({ serial: '0x1', shopName: null }) assert.equal(v.map, null) assert.equal(v.x, null) assert.equal(v.region, null) assert.equal(v.house, null) }) // `total` is what the SHOP holds; `count` is what the frame carried. A truncated // shop must not report its published slice as its size, or the page says // "showing 250 of 250" for a vendor holding three thousand stacks. test('flattenFrame keeps the shop total separate from the published count', () => { const v = market.flattenFrame({ ...FRAME, count: 250, total: 3104, truncated: true }) assert.equal(v.itemTotal, 3104) assert.equal(v.truncated, true) }) // An older plugin sends no `total`. Falling back to `count` is right — it is the // only number available and it is correct whenever nothing was truncated. test('flattenFrame falls back to count when total is absent', () => { const v = market.flattenFrame({ ...FRAME, count: 7, total: undefined }) assert.equal(v.itemTotal, 7) }) test('flattenFrame clips over-length strings rather than letting the insert fail', () => { const v = market.flattenFrame({ ...FRAME, ownerName: 'x'.repeat(200) }) assert.equal(v.ownerName.length, 64) }) // ── shapeItems ───────────────────────────────────────────────────────────── // // resolveMany never throws and, with no cliloc table reachable, resolves nothing // — which is exactly the state of a shard whose operator never converted one, so // these run against the real function rather than a stub. test('shapeItems prefers the item\'s literal name over its cliloc', async () => { const items = await market.shapeItems({ items: [{ serial: '0x1', itemId: 3922, price: 100, name: 'a shard sigil', cliloc: 1023721 }], }) assert.equal(items[0].displayName, 'a shard sigil') // The cliloc is kept regardless, so a later import can still re-resolve it. assert.equal(items[0].cliloc, 1023721) }) test('shapeItems resolves the cliloc when the item has no literal name', async () => { const items = await market.shapeItems({ items: [{ serial: '0x1', itemId: 3922, price: 100, name: null, cliloc: 1023721 }], }) assert.equal(items[0].displayName, 'quarter staff') }) // The supported state for a shard whose operator never converted a cliloc file: // no name, not a fabricated one. Clients render the item id, exactly as they did // before the table existed. test('shapeItems leaves displayName null for an unknown cliloc', async () => { const items = await market.shapeItems({ items: [{ serial: '0x1', itemId: 3922, price: 100, name: null, cliloc: 9999999 }], }) assert.equal(items[0].displayName, null) }) // Unpriced rows are inventory, not listings. The shard drops them too; enforcing // it here as well means a plugin that stops doing so cannot put un-buyable rows // on the market page. test('shapeItems drops unpriced listings', async () => { const items = await market.shapeItems({ items: [ { serial: '0x1', itemId: 1, price: 0 }, { serial: '0x2', itemId: 2, price: -1 }, { serial: '0x3', itemId: 3, price: 5 }, ], }) assert.deepEqual(items.map((i) => i.serial), ['0x3']) }) test('shapeItems caps a pathological frame', async () => { const many = Array.from({ length: market.MAX_ITEMS_PER_VENDOR + 50 }, (_, i) => ({ serial: `0x${i}`, itemId: 1, price: 1, })) const items = await market.shapeItems({ items: many }) assert.equal(items.length, market.MAX_ITEMS_PER_VENDOR) }) test('shapeItems tolerates a frame with no items array', async () => { assert.deepEqual(await market.shapeItems({}), []) }) // ── Visibility projection ────────────────────────────────────────────────── // // The regression that matters. Part A pre-wired `market.ownerName` and // `market.location` before the frame existed, and the sibling rule it pre-wired // for leaderboards (`characterName`) turned out to be INERT because projectValue // matches literal JSON keys. These assert the market rules actually bite — on the // read model AND on the wire frame, which is why both carry the same key names. const config = visibility.compileDefaults() const listing = market.shapeListing({ serial: '0x40012ABC', item_id: 3922, hue: 0, amount: 1, price: 25000, name: null, cliloc: 1023721, display_name: 'quarter staff', child: 0, vendor_serial: '0x40001234', shop_name: "Darrow's Bargains", owner_serial: '0x1A2B', owner_name: 'Darrow', map: 'Trammel', x: 1421, y: 1699, z: 0, region: 'Britain', house: "Darrow's Villa", updated_at: new Date(0), }) test('market defaults expose owner and location (they are already public in game)', () => { const out = visibility.projectFeature('market', listing, 'anonymous', config) assert.equal(out.vendor.ownerName, 'Darrow') assert.equal(out.vendor.location.region, 'Britain') }) test('tightening market.ownerName hides it from below that rung', () => { const tightened = { ...config, market: { ...config.market, fields: { ...config.market.fields, ownerName: 'staff' } } } const anon = visibility.projectFeature('market', listing, 'anonymous', tightened) const staff = visibility.projectFeature('market', listing, 'staff', tightened) assert.equal('ownerName' in anon.vendor, false) assert.equal(staff.vendor.ownerName, 'Darrow') // The shop name is a separate field and must survive — hiding the owner is not // the same as hiding the shop. assert.equal(anon.vendor.shopName, "Darrow's Bargains") }) // The whole reason `location` is one nested object: a single rule has to take the // facet, the coordinates, the region and the house together. Five flat keys would // be five rules that drift apart. test('tightening market.location hides the whole location object at once', () => { const tightened = { ...config, market: { ...config.market, fields: { ...config.market.fields, location: 'player' } } } const anon = visibility.projectFeature('market', listing, 'anonymous', tightened) const player = visibility.projectFeature('market', listing, 'player', tightened) assert.equal('location' in anon.vendor, false) assert.equal(player.vendor.location.map, 'Trammel') }) // The same rules must bite on the LIVE frame, not just the stored read model — // the market's SSE stream is off by default but an admin can turn it on, and a // field rule that only worked on one of the two paths is exactly the leak §3.6.1 // records. test('the same rules apply to the raw vendor.listing frame', () => { const tightened = { ...config, market: { ...config.market, fields: { ...config.market.fields, ownerName: 'admin', location: 'admin' } } } const out = visibility.projectFeature('market', FRAME, 'anonymous', tightened) assert.equal('ownerName' in out, false) assert.equal('location' in out, false) assert.equal(out.shopName, "Darrow's Bargains") }) // Rule 1 is not configurable and does not depend on the market rules at all: a // frame that somehow carried an account name must never publish it. test('acct and webId are stripped from a market payload regardless of config', () => { const out = visibility.projectFeature( 'market', { serial: '0x1', ownerAcct: 'darrow', webId: '42', shopName: 'Shop' }, 'staff', config, ) assert.equal('ownerAcct' in out, false) assert.equal('webId' in out, false) assert.equal(out.shopName, 'Shop') }) // Both kinds must be attributed to a feature, or rule 2 makes them admin-only by // omission — which would be a silent failure rather than a loud one. test('both market kinds are mapped to the market feature', () => { assert.equal(visibility.KIND_FEATURE.get('vendor.listing'), 'market') assert.equal(visibility.KIND_FEATURE.get('vendor.listing.remove'), 'market') }) // The market's live firehose is off by default (a page of whole vendor // inventories is the site's biggest bandwidth item and no page needs it live), // but the REST reads are unaffected — which is what `visibleKinds` ignoring the // stream flag encodes. test('market kinds are stream-suppressed by default but still readable', () => { assert.equal(visibility.DEFAULT_STREAM_OFF.has('market'), true) assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', config), false) assert.equal(visibility.PUBLIC_KINDS.has('vendor.listing'), false) assert.ok(visibility.visibleKinds('anonymous', config).includes('vendor.listing')) }) test('an admin who enables the stream gets the frames', () => { const on = { ...config, market: { ...config.market, stream: true } } assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', on), true) }) // Guards the stub above against silently doing nothing: if the model stopped // going through db.lookup, every shapeItems assertion would still "pass" by // resolving nothing, which is also what a real miss looks like. test('the cliloc resolver is the path shapeItems resolves through', async () => { const found = await clilocs.resolveMany([1023721]) assert.equal(found.get(1023721), 'quarter staff') })