diff --git a/packages/logger/lib/tags.js b/packages/logger/lib/tags.js index 06191d7ab..bbd3d49eb 100644 --- a/packages/logger/lib/tags.js +++ b/packages/logger/lib/tags.js @@ -29,6 +29,7 @@ const TAGS = /** @type {const} */ ({ geocoder: chalk.hex('#ff5722')('[GEOCODER]'), fetch: chalk.hex('#880e4f')('[FETCH]'), scanner: chalk.hex('#b39ddb')('[SCANNER]'), + golbat: chalk.hex('#ffb300')('[GOLBAT]'), build: chalk.hex('#ef6c00')('[BUILD]'), ReactMap: chalk.hex('#ff3d00')('[ReactMap]'), trial: chalk.hex('#fff320')('[TRIAL]'), diff --git a/packages/types/lib/server.d.ts b/packages/types/lib/server.d.ts index a7c42ea27..2b18f73f8 100644 --- a/packages/types/lib/server.d.ts +++ b/packages/types/lib/server.d.ts @@ -109,7 +109,13 @@ export interface AvailablePokestopShowcase { } export interface AvailablePokestops { - showcase_focus_filter: boolean + /** + * @deprecated Golbat advertises showcase focus support via + * GET /api/status `filters.showcase_focus` (see GolbatStatus). This + * per-response flag is only consulted for a Golbat whose status route + * reports no filters block, and will be dropped once such builds are gone. + */ + showcase_focus_filter?: boolean quests: AvailablePokestopQuest[] invasions: AvailablePokestopInvasion[] lures: AvailablePokestopLure[] @@ -125,6 +131,30 @@ export interface Available { tappables: ModelReturn } +/** + * Parsed GET /api/status from one Golbat instance. Every key a build defines + * is true; the signal is the key's presence. `filters` is null when the build + * predates the block (it then advertises no optional DNF filter fields), so + * consumers can fall back to older per-response flags for that build only. + */ +export interface GolbatStatus { + features: Record + limits: Record + filters: Record | null +} + +/** Registry entry kept by GolbatCapabilities, keyed by the endpoint base url. */ +export interface GolbatInstance { + mem: string + secret: string + httpAuth: { username: string; password: string } | null + /** Last good status; null until the instance has answered once. */ + status: GolbatStatus | null + /** Epoch ms of the last refresh start; drives the recheck debounce. */ + lastFetchAt: number + inflight: Promise | null +} + export interface ApiEndpoint { type: string endpoint: string diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 176706395..029c15eb9 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -30,6 +30,7 @@ const { getCombinedFortAvailable } = require('../utils/fortAvailable') const { buildPokestopDnfFilters } = require('../filters/fort/pokestop') const { describeDnfNarrowing } = require('../filters/fort/describeDnfNarrowing') const { state } = require('../services/state') +const { golbatCapabilities } = require('../services/GolbatCapabilities') const { isDualQuestLayerMode, resolveQuestLayerSelection, @@ -49,6 +50,21 @@ const TEMP_EVOLUTION_RESOURCE_REWARD_TYPES = [ TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE, ] +/** + * Whether the Golbat at `mem` applies contest_focus (showcase focus) inside + * its DNF matcher before the result cap. /api/status is the source of truth + * once a build advertises filters; an older build that does not is judged by + * the deprecated per-response `showcase_focus_filter` flag instead. + * @param {string} mem endpoint base url + * @param {{ showcase_focus_filter?: boolean }} payload /api/fort/available pokestops block + */ +function supportsShowcaseFocus(mem, payload) { + if (golbatCapabilities.advertisesFilters(mem)) { + return golbatCapabilities.supportsFilter(mem, 'showcase_focus') + } + return payload.showcase_focus_filter === true +} + /** @typedef {Partial} QuestReward */ const QUEST_REWARD_FILTER_DEFINITIONS = { @@ -1343,10 +1359,19 @@ class Pokestop extends Model { // contract. Do not let an outdated endpoint masquerade as a transient // failure and fall through to SQL. const hasPayload = res && typeof res === 'object' && !Array.isArray(res) - if (hasPayload && res.showcase_focus_filter !== true) { - throw new Error( - 'Golbat lacks the required showcase_focus_filter capability', - ) + if (hasPayload && !supportsShowcaseFocus(mem, res)) { + // The registry can lag a Golbat upgrade that dropped the legacy flag + // (its status was last read before the upgrade). Re-read it before + // the verdict becomes a hard error. This is refresh(), not the + // debounced recheck(): the upgrade may land seconds after a periodic + // status fetch, and this path is already throttled by the + // availability refresh window, so it cannot hammer Golbat. + await golbatCapabilities.refresh(mem) + if (!supportsShowcaseFocus(mem, res)) { + throw new Error( + 'Golbat lacks the required showcase_focus filter capability', + ) + } } // Transport failures and malformed responses may use the source's // normal SQL fallback. Keep only the mapper call inside this catch so diff --git a/server/src/models/pokestopAvailableMapper.js b/server/src/models/pokestopAvailableMapper.js index 113ec9757..24b35cf6f 100644 --- a/server/src/models/pokestopAvailableMapper.js +++ b/server/src/models/pokestopAvailableMapper.js @@ -45,7 +45,7 @@ * @property {Record|null} showcase_focus * * @typedef {object} AvailablePokestops - * @property {boolean} showcase_focus_filter + * @property {boolean} [showcase_focus_filter] deprecated — see GolbatStatus.filters.showcase_focus * @property {AvailablePokestopQuest[]} quests * @property {AvailablePokestopInvasion[]} invasions * @property {AvailablePokestopLure[]} lures diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index bdafe9bf9..45dab9e15 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -11,6 +11,7 @@ const { collapseRocketPokemonFilterKeys, } = require('../utils/rocketPokemonFiltering') const { getCache } = require('./cache') +const { golbatCapabilities } = require('./GolbatCapabilities') const STATION_BATTLE_REQUIRED_COLUMNS = [ 'station_id', @@ -292,6 +293,12 @@ class DbManager extends Logger { this.connections.length > 1 ? 's' : '' }`, ) + // Endpoint capability discovery is the /api/status counterpart of + // schemaCheck: it runs here so the registry is populated before the first + // availability refresh (startup and config reload both await this). + const discovery = golbatCapabilities + .discover(Object.values(this.endpoints)) + .catch((e) => this.log.error('Golbat capability discovery failed', e)) await Promise.all( this.connections.map(async (schema, i) => { try { @@ -363,6 +370,7 @@ class DbManager extends Logger { } }), ) + await discovery } /** diff --git a/server/src/services/GolbatCapabilities.js b/server/src/services/GolbatCapabilities.js new file mode 100644 index 000000000..322b1b558 --- /dev/null +++ b/server/src/services/GolbatCapabilities.js @@ -0,0 +1,252 @@ +// @ts-check +const { default: fetch } = require('node-fetch') + +const config = require('@rm/config') +const { Logger } = require('@rm/logger') + +const { buildScannerHeaders } = require('../utils/scannerHeaders') +const { setLongTimeout } = require('../utils/setLongTimeout') + +const STATUS_PATH = '/api/status' + +/** @param {unknown} value */ +const isPlainObject = (value) => + !!value && typeof value === 'object' && !Array.isArray(value) + +/** @returns {import('@rm/types').GolbatStatus} */ +const legacyStatus = () => ({ features: {}, limits: {}, filters: null }) + +/** + * Normalises a decoded /api/status body. A body that is not an object (an + * HTML error page that happened to parse, a bare array) counts as legacy. + * `filters` stays null when the block is absent so consumers can tell "this + * build does not advertise filters" from "this build advertises none". + * @param {unknown} body + * @returns {import('@rm/types').GolbatStatus} + */ +function parseStatus(body) { + if (!isPlainObject(body)) return legacyStatus() + const { features, limits, filters } = /** @type {Record} */ ( + body + ) + return { + features: isPlainObject(features) ? { ...features } : {}, + limits: isPlainObject(limits) ? { ...limits } : {}, + filters: isPlainObject(filters) ? { ...filters } : null, + } +} + +/** + * Discovers what each configured Golbat instance supports by reading + * GET /api/status, and keeps the answer per instance so consumers can ask + * `supportsFilter(mem, 'showcase_focus')` instead of probing feature calls. + * + * Instances are keyed by their endpoint base URL — the `mem` every scanner + * model already carries in its DbContext. + */ +class GolbatCapabilities extends Logger { + /** Upgrades are rare and a slow reaction is fine; the recheck fast path covers downgrades. */ + static REFRESH_MS = 5 * 60_000 + + /** Floor between immediate rechecks of one instance, so a burst of 4xx cannot hammer it. */ + static RECHECK_DEBOUNCE_MS = 30_000 + + /** + * @param {{ fetch?: typeof fetch, timeoutMs?: number, now?: () => number }} [options] + */ + constructor(options = {}) { + super('golbat') + this.fetch = options.fetch ?? fetch + this.timeoutMs = options.timeoutMs + this.now = options.now ?? Date.now + /** @type {Map} */ + this.instances = new Map() + /** @type {NodeJS.Timeout | null} */ + this.timer = null + } + + /** + * Replaces the registry with the given endpoints and fetches each status + * once, resolving when every fetch has settled. + * @param {{ endpoint: string, secret?: string, httpAuth?: { username: string, password: string } | null }[]} endpoints + */ + async discover(endpoints) { + const next = new Map() + endpoints.forEach(({ endpoint, secret, httpAuth }) => { + if (!endpoint || next.has(endpoint)) return + next.set(endpoint, { + mem: endpoint, + secret: secret || '', + httpAuth: httpAuth || null, + status: this.instances.get(endpoint)?.status ?? null, + lastFetchAt: 0, + inflight: null, + }) + }) + this.instances = next + this.#ensureTimer() + await this.refreshAll() + } + + /** Refreshes every registered instance, resolving once all have settled. */ + async refreshAll() { + await Promise.allSettled( + [...this.instances.keys()].map((mem) => this.refresh(mem)), + ) + } + + #ensureTimer() { + if (this.timer) return + this.timer = setInterval( + () => this.refreshAll(), + GolbatCapabilities.REFRESH_MS, + ) + this.timer.unref?.() + } + + /** + * Fast path for a failed feature call: a 4xx from a filter field is the first + * sign that a Golbat was downgraded, so re-read its status now rather than + * on the next interval. Debounced per instance and shares any in-flight + * refresh, so it is safe to call from a hot request path. + * @param {string} mem + */ + async recheck(mem) { + const instance = this.instances.get(mem) + if (!instance) return + if (instance.inflight) { + await instance.inflight + return + } + if ( + this.now() - instance.lastFetchAt < + GolbatCapabilities.RECHECK_DEBOUNCE_MS + ) { + return + } + await this.refresh(mem) + } + + /** + * Fetches /api/status for one instance and stores the parsed result. + * + * A 404 or a 2xx whose body is not JSON is an older Golbat and records a + * legacy (no capabilities) result. Any other outcome — a non-2xx such as + * 401/503, a network error, or the fetch timeout — is treated as transient: + * the last good result is kept so a Golbat blip does not churn behaviour. + * @param {string} mem + */ + async refresh(mem) { + const instance = this.instances.get(mem) + if (!instance) return + if (instance.inflight) { + await instance.inflight + return + } + instance.lastFetchAt = this.now() + instance.inflight = this.#fetchStatus(instance).finally(() => { + instance.inflight = null + }) + await instance.inflight + } + + /** @param {import('@rm/types').GolbatInstance} instance */ + async #fetchStatus(instance) { + const { mem } = instance + const controller = new AbortController() + const clearFetchTimeout = setLongTimeout( + () => controller.abort(), + this.timeoutMs ?? config.getSafe('api.fetchTimeoutMs'), + ) + try { + const response = await this.fetch(`${mem}${STATUS_PATH}`, { + method: 'GET', + headers: buildScannerHeaders(instance.secret, instance.httpAuth), + signal: controller.signal, + }) + let status + if (response.status === 404) { + status = legacyStatus() + } else if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } else { + let body + try { + body = await response.json() + } catch { + body = null + } + status = parseStatus(body) + } + this.#store(instance, status) + } catch (e) { + this.log.warn( + `${mem}${STATUS_PATH} unreachable (${e instanceof Error ? e.message : e}) — ${ + instance.status + ? 'keeping the last known capabilities' + : 'capabilities unknown until it answers' + }`, + ) + } finally { + clearFetchTimeout() + } + } + + /** + * Commits a freshly parsed status, logging at info only when it differs from + * the previous answer so the periodic refresh stays quiet. + * @param {import('@rm/types').GolbatInstance} instance + * @param {import('@rm/types').GolbatStatus} status + */ + #store(instance, status) { + const changed = JSON.stringify(instance.status) !== JSON.stringify(status) + instance.status = status + if (!changed) return + const filters = status.filters + ? `filters: ${Object.keys(status.filters).join(', ') || 'none'}` + : 'no filters block (older Golbat)' + const features = Object.keys(status.features).length + ? `features: ${Object.entries(status.features) + .map(([k, v]) => `${k}=${v}`) + .join(', ')}` + : 'no features block' + const limits = Object.keys(status.limits).length + ? `limits: ${Object.entries(status.limits) + .map(([k, v]) => `${k}=${v}`) + .join(', ')}` + : 'no limits block' + this.log.info(`${instance.mem} — ${filters}; ${features}; ${limits}`) + } + + /** + * @param {string} mem + * @returns {import('@rm/types').GolbatStatus | null} + */ + get(mem) { + return this.instances.get(mem)?.status ?? null + } + + /** @param {string} mem */ + advertisesFilters(mem) { + return !!this.get(mem)?.filters + } + + /** + * @param {string} mem + * @param {string} key + */ + supportsFilter(mem, key) { + return this.get(mem)?.filters?.[key] === true + } + + /** Cancels the periodic refresh. Discovery restarts it. */ + stop() { + if (this.timer) clearInterval(this.timer) + this.timer = null + } +} + +/** Process-wide registry; DbManager.getDbContext populates it at startup and on reload. */ +const golbatCapabilities = new GolbatCapabilities() + +module.exports = { GolbatCapabilities, golbatCapabilities } diff --git a/server/src/utils/evalScannerQuery.js b/server/src/utils/evalScannerQuery.js index 61c293a86..4027c4efd 100644 --- a/server/src/utils/evalScannerQuery.js +++ b/server/src/utils/evalScannerQuery.js @@ -5,6 +5,13 @@ const { resolve } = require('path') const config = require('@rm/config') const { log } = require('@rm/logger') const { fetchJson } = require('./fetchJson') +const { buildScannerHeaders } = require('./scannerHeaders') +const { golbatCapabilities } = require('../services/GolbatCapabilities') + +// Validation-class rejections. A Golbat that no longer knows a filter field +// answers one of these, which is the first sign it was downgraded — so the +// capability registry re-reads that instance's /api/status right away. +const CAPABILITY_RECHECK_STATUSES = new Set([400, 422]) /** * Endpoint-or-knex query evaluator shared by Golbat-backed scanner models. @@ -42,21 +49,21 @@ async function evalScannerQuery( const results = await (mem ? fetchJson(mem, { method, - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - ...(secret ? { 'X-Golbat-Secret': secret } : {}), - ...(httpAuth - ? { - Authorization: `Basic ${Buffer.from( - `${httpAuth.username}:${httpAuth.password}`, - ).toString('base64')}`, - } - : {}), - }, + headers: buildScannerHeaders(secret, httpAuth), body: query, }) : query) + const apiPathIndex = mem ? mem.indexOf('/api/') : -1 + if ( + apiPathIndex > 0 && + results && + typeof results === 'object' && + CAPABILITY_RECHECK_STATUSES.has(results.status) + ) { + golbatCapabilities + .recheck(mem.slice(0, apiPathIndex)) + .catch((e) => log.warn(tag, 'capability recheck failed', e)) + } log.debug(tag, 'raw result length', results?.length || 0) return results } diff --git a/server/src/utils/scannerHeaders.js b/server/src/utils/scannerHeaders.js new file mode 100644 index 000000000..9ee032131 --- /dev/null +++ b/server/src/utils/scannerHeaders.js @@ -0,0 +1,27 @@ +// @ts-check + +/** + * Request headers for a Golbat HTTP call: JSON content negotiation plus the + * optional API secret and HTTP basic auth a source is configured with. Shared + * by the scanner query evaluator and the capability discovery service so both + * authenticate the same way. + * @param {string} [secret] + * @param {{ username: string, password: string } | null} [httpAuth] + * @returns {Record} + */ +function buildScannerHeaders(secret = '', httpAuth = null) { + return { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(secret ? { 'X-Golbat-Secret': secret } : {}), + ...(httpAuth + ? { + Authorization: `Basic ${Buffer.from( + `${httpAuth.username}:${httpAuth.password}`, + ).toString('base64')}`, + } + : {}), + } +} + +module.exports = { buildScannerHeaders } diff --git a/server/test/golbatCapabilities.test.js b/server/test/golbatCapabilities.test.js new file mode 100644 index 000000000..fb64c82d3 --- /dev/null +++ b/server/test/golbatCapabilities.test.js @@ -0,0 +1,333 @@ +const assert = require('node:assert/strict') +const { test } = require('node:test') + +const { GolbatCapabilities } = require('../src/services/GolbatCapabilities') + +const MEM = 'http://golbat-a' + +/** + * Builds a fetch stand-in whose reply can be swapped between calls. `reply` is + * a function of the url so a test can vary the answer per instance. + * @param {(url: string, init: any) => { status: number, body?: any, text?: string }} reply + */ +function fakeFetch(reply) { + const calls = [] + const fetchImpl = async (url, init) => { + calls.push({ url, init }) + const res = reply(url, init) + return { + ok: res.status >= 200 && res.status < 300, + status: res.status, + statusText: '', + json: async () => { + if ('body' in res) return res.body + throw new SyntaxError(`Unexpected token in JSON: ${res.text}`) + }, + } + } + return { fetchImpl, calls } +} + +const STATUS_WITH_FILTERS = { + features: { fort_in_memory: true }, + limits: { max_pokemon_results: 3000, max_fort_results: 9000 }, + filters: { showcase_focus: true, battle_available: true }, +} + +test('an old Golbat with no status route yields a legacy result without filters', async (t) => { + const { fetchImpl, calls } = fakeFetch(() => ({ status: 404 })) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + + await caps.discover([{ endpoint: MEM, secret: 's3cret' }]) + + assert.equal(calls.length, 1) + assert.equal(calls[0].url, `${MEM}/api/status`) + assert.deepEqual(caps.get(MEM), { features: {}, limits: {}, filters: null }) + assert.equal(caps.advertisesFilters(MEM), false) + assert.equal(caps.supportsFilter(MEM, 'showcase_focus'), false) +}) + +test('sends the Golbat secret and basic auth headers on the status request', async (t) => { + const { fetchImpl, calls } = fakeFetch(() => ({ + status: 200, + body: STATUS_WITH_FILTERS, + })) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + + await caps.discover([ + { + endpoint: MEM, + secret: 's3cret', + httpAuth: { username: 'u', password: 'p' }, + }, + ]) + + assert.equal(calls[0].init.method, 'GET') + assert.equal(calls[0].init.headers['X-Golbat-Secret'], 's3cret') + assert.equal( + calls[0].init.headers.Authorization, + `Basic ${Buffer.from('u:p').toString('base64')}`, + ) + assert.equal(calls[0].init.headers.Accept, 'application/json') +}) + +test('a status body without a filters block records features and limits only', async (t) => { + const { fetchImpl } = fakeFetch(() => ({ + status: 200, + body: { + features: { fort_in_memory: true }, + limits: { max_pokemon_results: 3000, max_fort_results: 9000 }, + }, + })) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + + await caps.discover([{ endpoint: MEM }]) + + assert.deepEqual(caps.get(MEM), { + features: { fort_in_memory: true }, + limits: { max_pokemon_results: 3000, max_fort_results: 9000 }, + filters: null, + }) + assert.equal(caps.advertisesFilters(MEM), false) + assert.equal(caps.supportsFilter(MEM, 'showcase_focus'), false) +}) + +test('a status body with a filters block advertises the keys it lists', async (t) => { + const { fetchImpl } = fakeFetch(() => ({ + status: 200, + body: STATUS_WITH_FILTERS, + })) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + + await caps.discover([{ endpoint: MEM }]) + + assert.deepEqual(caps.get(MEM), STATUS_WITH_FILTERS) + assert.equal(caps.advertisesFilters(MEM), true) + assert.equal(caps.supportsFilter(MEM, 'showcase_focus'), true) + assert.equal(caps.supportsFilter(MEM, 'battle_available'), true) + assert.equal(caps.supportsFilter(MEM, 'not_a_filter'), false) +}) + +test('a 2xx that is not JSON counts as an old Golbat, not an outage', async (t) => { + const { fetchImpl } = fakeFetch(() => ({ + status: 200, + text: 'not golbat', + })) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + + await caps.discover([{ endpoint: MEM }]) + + assert.deepEqual(caps.get(MEM), { features: {}, limits: {}, filters: null }) +}) + +test('a transient failure keeps the last good result', async (t) => { + let reply = { status: 200, body: STATUS_WITH_FILTERS } + const { fetchImpl } = fakeFetch(() => reply) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + + await caps.discover([{ endpoint: MEM }]) + assert.equal(caps.supportsFilter(MEM, 'showcase_focus'), true) + + reply = { status: 500, text: 'boom' } + await caps.refresh(MEM) + assert.equal(caps.supportsFilter(MEM, 'showcase_focus'), true) + + reply = { status: 401, text: 'bad secret' } + await caps.refresh(MEM) + assert.equal(caps.supportsFilter(MEM, 'showcase_focus'), true) + + const networkError = async () => { + throw new Error('ECONNREFUSED') + } + caps.fetch = networkError + await caps.refresh(MEM) + assert.deepEqual(caps.get(MEM), STATUS_WITH_FILTERS) +}) + +test('a transient failure before any success leaves the instance unknown', async (t) => { + const { fetchImpl } = fakeFetch(() => ({ status: 503, text: 'starting' })) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + + await caps.discover([{ endpoint: MEM }]) + + assert.equal(caps.get(MEM), null) + assert.equal(caps.advertisesFilters(MEM), false) + assert.equal(caps.supportsFilter(MEM, 'showcase_focus'), false) +}) + +test('a refresh after a Golbat upgrade picks up the new filters block', async (t) => { + let reply = { status: 404 } + const { fetchImpl } = fakeFetch(() => reply) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + + await caps.discover([{ endpoint: MEM }]) + assert.equal(caps.advertisesFilters(MEM), false) + + reply = { status: 200, body: STATUS_WITH_FILTERS } + await caps.refresh(MEM) + assert.equal(caps.advertisesFilters(MEM), true) + assert.equal(caps.supportsFilter(MEM, 'showcase_focus'), true) +}) + +test('a request that exceeds the fetch timeout is aborted and keeps the last good result', async (t) => { + const reply = { status: 200, body: STATUS_WITH_FILTERS } + const { fetchImpl } = fakeFetch(() => reply) + const hanging = (url, init) => + new Promise((_, reject) => { + init.signal.addEventListener('abort', () => + reject(new Error('The operation was aborted')), + ) + }) + const caps = new GolbatCapabilities({ fetch: fetchImpl, timeoutMs: 5 }) + t.after(() => caps.stop()) + + await caps.discover([{ endpoint: MEM }]) + caps.fetch = hanging + await caps.refresh(MEM) + + assert.deepEqual(caps.get(MEM), STATUS_WITH_FILTERS) +}) + +test('rediscovery drops instances that are no longer configured and keeps the rest', async (t) => { + const OTHER = 'http://golbat-b' + const { fetchImpl, calls } = fakeFetch((url) => ({ + status: url.startsWith(OTHER) ? 404 : 200, + body: STATUS_WITH_FILTERS, + })) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + + await caps.discover([ + { endpoint: MEM }, + { endpoint: OTHER }, + { endpoint: OTHER }, + ]) + assert.equal(calls.length, 2) + assert.equal(caps.supportsFilter(MEM, 'showcase_focus'), true) + assert.equal(caps.advertisesFilters(OTHER), false) + + await caps.discover([{ endpoint: OTHER }]) + assert.equal(caps.get(MEM), null) + assert.equal(caps.get(OTHER) !== null, true) +}) + +test('discovery starts a periodic refresh that stop() cancels', async (t) => { + t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] }) + const { fetchImpl, calls } = fakeFetch(() => ({ + status: 200, + body: STATUS_WITH_FILTERS, + })) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + + await caps.discover([{ endpoint: MEM }, { endpoint: 'http://golbat-b' }]) + assert.equal(calls.length, 2) + + t.mock.timers.tick(GolbatCapabilities.REFRESH_MS - 1) + assert.equal(calls.length, 2) + t.mock.timers.tick(1) + assert.equal(calls.length, 4) + + caps.stop() + t.mock.timers.tick(GolbatCapabilities.REFRESH_MS) + assert.equal(calls.length, 4) +}) + +test('concurrent refreshes of one instance share a single request', async (t) => { + let release + const gate = new Promise((resolve) => { + release = resolve + }) + const calls = [] + const fetchImpl = async (url, init) => { + calls.push({ url, init }) + await gate + return { + ok: true, + status: 200, + statusText: '', + json: async () => STATUS_WITH_FILTERS, + } + } + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + caps.instances.set(MEM, { + mem: MEM, + secret: '', + httpAuth: null, + status: null, + }) + + const first = caps.refresh(MEM) + const second = caps.refresh(MEM) + release() + await Promise.all([first, second]) + + assert.equal(calls.length, 1) + assert.equal(caps.supportsFilter(MEM, 'showcase_focus'), true) +}) + +test('recheck refreshes immediately but at most once per debounce window', async (t) => { + let now = 1_000_000 + const { fetchImpl, calls } = fakeFetch(() => ({ + status: 200, + body: STATUS_WITH_FILTERS, + })) + const caps = new GolbatCapabilities({ fetch: fetchImpl, now: () => now }) + t.after(() => caps.stop()) + + await caps.discover([{ endpoint: MEM }]) + assert.equal(calls.length, 1) + + now += GolbatCapabilities.RECHECK_DEBOUNCE_MS + await caps.recheck(MEM) + assert.equal(calls.length, 2) + + await caps.recheck(MEM) + assert.equal(calls.length, 2) + + now += GolbatCapabilities.RECHECK_DEBOUNCE_MS - 1 + await caps.recheck(MEM) + assert.equal(calls.length, 2) + + now += 1 + await caps.recheck(MEM) + assert.equal(calls.length, 3) + + await caps.recheck('http://not-configured') + assert.equal(calls.length, 3) +}) + +test('a changed status is logged at info and an unchanged refresh is not', async (t) => { + let reply = { status: 404 } + const { fetchImpl } = fakeFetch(() => reply) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + const info = t.mock.method(caps.log, 'info', () => {}) + + await caps.discover([{ endpoint: MEM }]) + assert.equal(info.mock.callCount(), 1) + assert.match(String(info.mock.calls[0].arguments[0]), /no filters block/) + + await caps.refresh(MEM) + assert.equal(info.mock.callCount(), 1) + + reply = { status: 200, body: STATUS_WITH_FILTERS } + await caps.refresh(MEM) + assert.equal(info.mock.callCount(), 2) + assert.match( + String(info.mock.calls[1].arguments[0]), + /showcase_focus, battle_available/, + ) + + await caps.refresh(MEM) + assert.equal(info.mock.callCount(), 2) +}) diff --git a/server/test/scannerCapabilityRecheck.test.js b/server/test/scannerCapabilityRecheck.test.js new file mode 100644 index 000000000..6b5cac87f --- /dev/null +++ b/server/test/scannerCapabilityRecheck.test.js @@ -0,0 +1,47 @@ +const assert = require('node:assert/strict') +const { test, mock } = require('node:test') + +// evalScannerQuery reads fetchJson at load, so stub the module before it loads. +let reply +mock.module(require.resolve('../src/utils/fetchJson'), { + cache: true, + namedExports: { fetchJson: async () => reply }, +}) + +const { golbatCapabilities } = require('../src/services/GolbatCapabilities') +const { evalScannerQuery } = require('../src/utils/evalScannerQuery') + +const MEM = 'http://golbat-a' + +test('a 4xx from a Golbat feature call rechecks that instance immediately', async (t) => { + const rechecked = [] + t.mock.method(golbatCapabilities, 'recheck', async (mem) => { + rechecked.push(mem) + }) + + reply = { status: 422, statusText: 'Unprocessable Entity' } + await evalScannerQuery('tag', `${MEM}/api/pokestop/scan`, '{}', 'POST') + assert.deepEqual(rechecked, [MEM]) + + reply = { status: 400, statusText: 'Bad Request' } + await evalScannerQuery('tag', `${MEM}/api/pokemon/v3/scan`, '{}', 'POST') + assert.deepEqual(rechecked, [MEM, MEM]) +}) + +test('other outcomes of a Golbat call do not trigger a recheck', async (t) => { + const rechecked = [] + t.mock.method(golbatCapabilities, 'recheck', async (mem) => { + rechecked.push(mem) + }) + + reply = { pokestops: [] } + await evalScannerQuery('tag', `${MEM}/api/pokestop/scan`, '{}', 'POST') + reply = { status: 404, statusText: 'Not Found' } + await evalScannerQuery('tag', `${MEM}/api/pokestop/id/abc`, undefined, 'GET') + reply = { status: 503, statusText: 'Service Unavailable' } + await evalScannerQuery('tag', `${MEM}/api/fort/available`, undefined, 'GET') + reply = undefined + await evalScannerQuery('tag', `${MEM}/api/pokestop/scan`, '{}', 'POST') + + assert.deepEqual(rechecked, []) +}) diff --git a/server/test/showcaseEndpointAvailability.test.js b/server/test/showcaseEndpointAvailability.test.js index 3df8ba9da..690d3ee11 100644 --- a/server/test/showcaseEndpointAvailability.test.js +++ b/server/test/showcaseEndpointAvailability.test.js @@ -6,74 +6,169 @@ require('./stateMock') const fortAvailable = require('../src/utils/fortAvailable') -test('endpoint availability uses Golbat Showcase focus without SQL supplementation', async (t) => { - let pokestops = { - showcase_focus_filter: true, - quests: [], - invasions: [{ character: 0, display_type: 9 }], - lures: [], - showcases: [ - { - pokemon_id: null, - form: null, - type_id: null, - showcase_focus: { type: 'buddy', min_level: 3 }, - }, - ], - } +const { golbatCapabilities } = require('../src/services/GolbatCapabilities') + +const MEM = 'http://unused-golbat' + +const CONTEXT = { + hasAltQuests: false, + hasMultiInvasions: true, + multiInvasionMs: false, + hasRewardAmount: true, + hasConfirmed: true, + hasShowcaseData: false, + hasShowcaseForm: false, + hasShowcaseType: false, + hasShowcaseFocus: false, + mem: MEM, + secret: '', + httpAuth: null, +} + +/** A combined-availability pokestop payload carrying one Buddy showcase. */ +const showcasePayload = (extra = {}) => ({ + quests: [], + invasions: [{ character: 0, display_type: 9 }], + lures: [], + showcases: [ + { + pokemon_id: null, + form: null, + type_id: null, + showcase_focus: { type: 'buddy', min_level: 3 }, + }, + ], + ...extra, +}) + +/** + * Loads a fresh Pokestop model whose combined-availability fetch returns + * `getPokestops()` and whose SQL path counts (and rejects) any query. + * @param {import('node:test').TestContext} t + * @param {() => object} getPokestops + */ +function loadPokestop(t, getPokestops) { t.mock.method(fortAvailable, 'getCombinedFortAvailable', async () => ({ - pokestops, + pokestops: getPokestops(), })) - const pokestopModule = require.resolve('../src/models/Pokestop') delete require.cache[pokestopModule] const { Pokestop } = require('../src/models/Pokestop') const originalQuery = Pokestop.query - let sqlExecutions = 0 - + const sql = { executions: 0 } t.after(() => { Pokestop.query = originalQuery delete require.cache[pokestopModule] }) - Pokestop.query = () => { - sqlExecutions += 1 + sql.executions += 1 throw new Error('endpoint availability must not query SQL') } + return { Pokestop, sql } +} + +/** + * Pins the capability registry's answer for MEM. `filters` null = the status + * route reported no filters block (older Golbat). + * @param {import('node:test').TestContext} t + * @param {Record | null} filters + */ +function mockStatus(t, filters) { + t.mock.method(golbatCapabilities, 'advertisesFilters', (mem) => + mem === MEM ? filters !== null : false, + ) + t.mock.method(golbatCapabilities, 'supportsFilter', (mem, key) => + mem === MEM ? filters?.[key] === true : false, + ) + t.mock.method(golbatCapabilities, 'recheck', async () => {}) +} - const context = { - hasAltQuests: false, - hasMultiInvasions: true, - multiInvasionMs: false, - hasRewardAmount: true, - hasConfirmed: true, - hasShowcaseData: false, - hasShowcaseForm: false, - hasShowcaseType: false, - hasShowcaseFocus: false, - mem: 'http://unused-golbat', - secret: '', - httpAuth: null, - } +test('a Golbat that advertises showcase_focus serves availability without the legacy flag', async (t) => { + mockStatus(t, { showcase_focus: true }) + const { Pokestop, sql } = loadPokestop(t, () => showcasePayload()) + + const result = await Pokestop.getAvailable(CONTEXT) - const result = await Pokestop.getAvailable(context) assert.deepEqual(result.available, ['b9', 'y3']) - assert.equal(sqlExecutions, 0) + assert.equal(sql.executions, 0) +}) - pokestops = { ...pokestops, showcase_focus_filter: false } +test('a Golbat that advertises filters without showcase_focus is rejected even if the legacy flag says true', async (t) => { + mockStatus(t, { battle_available: true }) + const { Pokestop, sql } = loadPokestop(t, () => + showcasePayload({ showcase_focus_filter: true }), + ) + + await assert.rejects( + Pokestop.getAvailable(CONTEXT), + /required showcase_focus filter capability/, + ) + assert.equal(sql.executions, 0) +}) + +test('a Golbat without a filters block is judged by the legacy showcase_focus_filter flag', async (t) => { + mockStatus(t, null) + let pokestops = showcasePayload({ showcase_focus_filter: true }) + const { Pokestop, sql } = loadPokestop(t, () => pokestops) + + const result = await Pokestop.getAvailable(CONTEXT) + assert.deepEqual(result.available, ['b9', 'y3']) + + pokestops = showcasePayload({ showcase_focus_filter: false }) await assert.rejects( - Pokestop.getAvailable(context), - /required showcase_focus_filter capability/, + Pokestop.getAvailable(CONTEXT), + /required showcase_focus filter capability/, ) - assert.equal(sqlExecutions, 0) - pokestops = { ...pokestops } - delete pokestops.showcase_focus_filter + pokestops = showcasePayload() await assert.rejects( - Pokestop.getAvailable(context), - /required showcase_focus_filter capability/, + Pokestop.getAvailable(CONTEXT), + /required showcase_focus filter capability/, ) - assert.equal(sqlExecutions, 0) + assert.equal(sql.executions, 0) +}) + +test('an unsupported verdict re-reads the status even seconds after the last fetch', async (t) => { + // Real registry, fake transport: the status route answers as an older + // Golbat at discovery, then as an upgraded build that dropped the legacy + // flag. The upgrade lands inside the recheck debounce window, so a + // debounced recheck would skip and the pass would throw. + let now = 1_000_000 + let statusReply = { status: 404 } + const statusCalls = [] + const originalFetch = golbatCapabilities.fetch + const originalNow = golbatCapabilities.now + t.after(() => { + golbatCapabilities.fetch = originalFetch + golbatCapabilities.now = originalNow + golbatCapabilities.instances = new Map() + golbatCapabilities.stop() + }) + golbatCapabilities.now = () => now + golbatCapabilities.fetch = async (url) => { + statusCalls.push(url) + return { + ok: statusReply.status === 200, + status: statusReply.status, + statusText: '', + json: async () => statusReply.body, + } + } + await golbatCapabilities.discover([{ endpoint: MEM }]) + assert.equal(golbatCapabilities.advertisesFilters(MEM), false) + + statusReply = { + status: 200, + body: { features: {}, limits: {}, filters: { showcase_focus: true } }, + } + now += 10_000 + const { Pokestop, sql } = loadPokestop(t, () => showcasePayload()) + + const result = await Pokestop.getAvailable(CONTEXT) + + assert.equal(statusCalls.length, 2) + assert.deepEqual(result.available, ['b9', 'y3']) + assert.equal(sql.executions, 0) }) test('malformed endpoint availability falls through to dual-source SQL', async (t) => {