diff --git a/frontend/composables/use-maxmind.js b/frontend/composables/use-maxmind.js index 1911b2e1..076967dc 100644 --- a/frontend/composables/use-maxmind.js +++ b/frontend/composables/use-maxmind.js @@ -3,12 +3,43 @@ // Returns `{ country_code, country, org, asn }` on success. Returns `null` on // every failure path (no MaxMind source, network error, malformed // upstream payload) so callers don't have to disambiguate. +// Lookups are deduped module-wide by ip+lang: the WebRTC test resolves the +// same public IP from several STUN servers and each one triggers a lookup — +// they all share one request, and successful results are cached for the +// session (geo data doesn't change mid-visit). Failures are not cached, so a +// transient error stays retryable. -import { useMainStore } from '@/store'; +// Relative imports (not the @ alias) keep this file importable from the Node +// test runner, same as the other spec-covered composables. +import { useMainStore } from '../store.js'; import { useI18n } from 'vue-i18n'; -import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js'; -import { transformDataFromIPapi } from '@/utils/transform-ip-data.js'; -import getCountryName from '@/data/country-name.js'; +import { fetchWithTimeout } from '../utils/fetch-with-timeout.js'; +import { transformDataFromIPapi } from '../utils/transform-ip-data.js'; +import getCountryName from '../data/country-name.js'; + +// key → resolved result (successes only) / key → in-flight promise. +const lookupCache = new Map(); +const pendingLookups = new Map(); + +// Pure dedup wrapper (exported for tests): concurrent calls with the same key +// await the same doLookup() run; non-null results are cached permanently. +export const dedupedLookup = async (key, doLookup) => { + if (lookupCache.has(key)) return lookupCache.get(key); + const pending = pendingLookups.get(key); + if (pending) return pending; + + const promise = (async () => { + const result = await doLookup(); + if (result !== null) lookupCache.set(key, result); + return result; + })(); + pendingLookups.set(key, promise); + try { + return await promise; + } finally { + pendingLookups.delete(key); + } +}; export function useMaxmind() { const store = useMainStore(); @@ -22,23 +53,26 @@ export function useMaxmind() { // ip-api.com style locale tag — same mapping the legacy call sites used. const apiLang = lang === 'zh' ? 'zh-CN' : lang; - try { - const url = store.getDbUrl(source.id, ip, apiLang); - const response = await fetchWithTimeout(url); - const data = await response.json(); - const ipData = transformDataFromIPapi(data, source.id, t, lang); - if (!ipData) return null; - const country_code = (ipData.country_code || '').toLowerCase(); - const country = country_code - ? getCountryName(ipData.country_code, lang) - : ''; - // Upstream reports unknown ASN as the literal 'N/A' — normalize to ''. - const asn = ipData.asn && ipData.asn !== 'N/A' ? ipData.asn : ''; - return { country_code, country, org: ipData.isp || '', asn }; - } catch (error) { - console.error('useMaxmind lookup failed', error); - return null; - } + // `country` below is localized, so the cache key must carry the lang. + return dedupedLookup(`${ip}|${lang}`, async () => { + try { + const url = store.getDbUrl(source.id, ip, apiLang); + const response = await fetchWithTimeout(url); + const data = await response.json(); + const ipData = transformDataFromIPapi(data, source.id, t, lang); + if (!ipData) return null; + const country_code = (ipData.country_code || '').toLowerCase(); + const country = country_code + ? getCountryName(ipData.country_code, lang) + : ''; + // Upstream reports unknown ASN as the literal 'N/A' — normalize to ''. + const asn = ipData.asn && ipData.asn !== 'N/A' ? ipData.asn : ''; + return { country_code, country, org: ipData.isp || '', asn }; + } catch (error) { + console.error('useMaxmind lookup failed', error); + return null; + } + }); }; return { lookupMaxmind }; diff --git a/tests/composable-maxmind.test.js b/tests/composable-maxmind.test.js new file mode 100644 index 00000000..1130f488 --- /dev/null +++ b/tests/composable-maxmind.test.js @@ -0,0 +1,108 @@ +// Tests for the module-level lookup dedup in +// frontend/composables/use-maxmind.js: concurrent same-key calls share one +// doLookup run, successful results cache for the session, null results and +// rejections stay retryable. Each test uses its own keys — the cache is +// module-global and never reset. +// +// Importing the composable pulls in the real Pinia store, which needs the +// same globalThis stubs as store.test.js — installed before the import below. + +globalThis.localStorage = { + _data: {}, + getItem(k) { return this._data[k] ?? null; }, + setItem(k, v) { this._data[k] = v; }, + removeItem(k) { delete this._data[k]; }, + clear() { this._data = {}; }, +}; +globalThis.window = { + location: { search: '' }, + addEventListener() {}, + innerWidth: 1024, +}; +globalThis.document = { + addEventListener() {}, + title: '', + querySelector() { return null; }, + // @vue/runtime-dom (pulled in by vue-i18n) creates a