Files
MyIP/tests/maxmind-service.test.js
jason5ng32andClaude Opus 5.5 c29e48687b Refactor(layout): split backend-only code out of common/, group frontend utils
common/ had become the backend's library: 30 of its 41 modules were
imported by nothing in the frontend, so the name no longer said what
belonged there. It now holds only what the frontend or the build imports
too; backend-only code moved to server/, flat except for datasets/ (the
dataset engine and its readers). The *-db/ dataset directories stay in
common/: self-hosting docs and Docker volumes mount those paths.

frontend/utils/ keeps its app-wide modules at the root and gains ip/
(IP / ASN shaping and validation), report/ (the report pipeline) and
features/ (the pure layer of a single feature). Paths only — no logic
changes. The Dockerfile copies server/, and the AGENTS.md files say where
a new module goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:10:02 +08:00

175 lines
6.8 KiB
JavaScript

// Contract tests for server/datasets/maxmind-service.
//
// The repo ships real GeoLite2-ASN.mmdb + GeoLite2-City.mmdb binaries, so we
// exercise openMaxMindReaders() / reloadMaxMindDatabases() against the actual
// database files end-to-end rather than mocking fs.
//
// The language set and its resolver are pure and run regardless.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { describe, it, before } from 'node:test';
import {
MAXMIND_DB_DIR,
MAXMIND_CITY_DB,
MAXMIND_ASN_DB,
getMaxMindDbPaths,
isMaxMindReady,
openMaxMindReaders,
reloadMaxMindDatabases,
lookupMaxMind,
SUPPORTED_LANGS,
normalizeLang,
} from '../server/datasets/maxmind-service.js';
// The repo should ship maxmind database files; if not on CI, skip related tests
const cityPath = path.join(MAXMIND_DB_DIR, MAXMIND_CITY_DB);
const asnPath = path.join(MAXMIND_DB_DIR, MAXMIND_ASN_DB);
const dbsAvailable = fs.existsSync(cityPath) && fs.existsSync(asnPath);
describe('maxmind-service — supported languages', () => {
it('is the set the City database carries names for', () => {
// Verified against the shipped GeoLite2-City `names` objects; it tracks the
// upstream data, not the UI locale registry.
assert.deepEqual(SUPPORTED_LANGS, ['de', 'en', 'es', 'fr', 'ja', 'pt-BR', 'ru', 'zh-CN']);
});
it('contains en, the universal fallback', () => {
assert.ok(SUPPORTED_LANGS.includes('en'));
});
});
describe('maxmind-service — normalizeLang', () => {
it('returns the canonical spelling on an exact hit', () => {
for (const tag of SUPPORTED_LANGS) assert.equal(normalizeLang(tag), tag);
});
it('is case-insensitive and trims surrounding whitespace', () => {
assert.equal(normalizeLang('ZH-CN'), 'zh-CN');
assert.equal(normalizeLang('pt-br'), 'pt-BR');
assert.equal(normalizeLang(' FR '), 'fr');
});
it('keeps a supported base language', () => {
assert.equal(normalizeLang('de-AT'), 'de');
assert.equal(normalizeLang('en-GB'), 'en');
assert.equal(normalizeLang('es-419'), 'es');
});
it('falls sideways to a sibling when the base itself is unsupported', () => {
assert.equal(normalizeLang('zh'), 'zh-CN');
assert.equal(normalizeLang('zh-TW'), 'zh-CN');
assert.equal(normalizeLang('zh-Hant-HK'), 'zh-CN');
assert.equal(normalizeLang('pt'), 'pt-BR');
assert.equal(normalizeLang('pt-PT'), 'pt-BR');
});
it('sends an unsupported language to en', () => {
for (const tag of ['tr', 'ko', 'ar-EG', 'nl']) assert.equal(normalizeLang(tag), 'en');
});
it('never throws on junk input', () => {
for (const value of [undefined, null, '', ' ', 42, {}, [], true, '-', '???']) {
assert.equal(normalizeLang(value), 'en');
}
});
});
describe('maxmind-service — path + ready contract', () => {
it('getMaxMindDbPaths returns City + ASN paths under MAXMIND_DB_DIR', () => {
const paths = getMaxMindDbPaths();
assert.equal(paths.dbDir, MAXMIND_DB_DIR);
assert.equal(path.basename(paths.cityDbPath), 'GeoLite2-City.mmdb');
assert.equal(path.basename(paths.asnDbPath), 'GeoLite2-ASN.mmdb');
assert.equal(path.dirname(paths.cityDbPath), MAXMIND_DB_DIR);
});
it('exported constants match the file names they describe', () => {
assert.equal(MAXMIND_CITY_DB, 'GeoLite2-City.mmdb');
assert.equal(MAXMIND_ASN_DB, 'GeoLite2-ASN.mmdb');
});
it('lookupMaxMind throws 503-tagged error when readers are not ready', () => {
// reloadMaxMindDatabases test before hook will install readers;
// this test is placed before the before hook (describe top-level it runs in declaration order)
// so this test can hit the not-ready path before the first before hook runs.
// if DB is not in the repo, isMaxMindReady()===false, this test will hit the not-ready path.
if (isMaxMindReady()) return; // already ready, skip
try {
lookupMaxMind('1.1.1.1');
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.message, 'MaxMind database is not ready');
assert.equal(err.statusCode, 503);
}
});
});
describe('maxmind-service — end-to-end with real DB (skipped if files missing)', () => {
before(async () => {
if (!dbsAvailable) return;
await reloadMaxMindDatabases('test-setup');
});
it('reloadMaxMindDatabases flips isMaxMindReady() to true', () => {
if (!dbsAvailable) return;
assert.equal(isMaxMindReady(), true);
});
it('openMaxMindReaders returns both city + asn readers', async () => {
if (!dbsAvailable) return;
const { city, asn } = await openMaxMindReaders();
assert.ok(city && typeof city.get === 'function');
assert.ok(asn && typeof asn.get === 'function');
});
it('lookupMaxMind returns the expected stable shape for a known public IP', () => {
if (!dbsAvailable) return;
const result = lookupMaxMind('8.8.8.8', 'en');
// Only assert the shape — actual values depend on the shipped DB version
const expectedKeys = [
'ip', 'city', 'region', 'country', 'country_name', 'country_code',
'latitude', 'longitude', 'asn', 'org',
];
for (const k of expectedKeys) {
assert.ok(k in result, `missing key: ${k}`);
}
assert.equal(result.ip, '8.8.8.8');
// 8.8.8.8 is Google (AS15169); DB may use either AS prefix (AS15169 or AS15169-1)
assert.match(String(result.asn), /^AS\d+$/);
});
it('lookupMaxMind gracefully returns N/A fields for a private / unknown IP', () => {
if (!dbsAvailable) return;
const result = lookupMaxMind('10.0.0.1', 'en');
// 10.0.0.0/8 is private — MaxMind has no record → formatter falls back to N/A
assert.equal(result.ip, '10.0.0.1');
assert.equal(result.city, 'N/A');
assert.equal(result.country, 'N/A');
assert.equal(result.asn, 'N/A');
});
it('lookupMaxMind serves a supported language from the database', () => {
if (!dbsAvailable) return;
// A CN address has genuinely different names per language, so this catches
// a lookup that silently answers in English.
assert.equal(lookupMaxMind('223.5.5.5', 'zh-CN').country_name, '中国');
assert.equal(lookupMaxMind('223.5.5.5', 'ru').country_name, 'Китай');
assert.equal(lookupMaxMind('223.5.5.5', 'en').country_name, 'China');
});
it('lookupMaxMind normalizes the tag itself, so no caller can skip it', () => {
if (!dbsAvailable) return;
const canonical = lookupMaxMind('223.5.5.5', 'zh-CN');
// Family variants and sloppy casing reach the same names...
assert.deepEqual(lookupMaxMind('223.5.5.5', 'zh-TW'), canonical);
assert.deepEqual(lookupMaxMind('223.5.5.5', ' zh '), canonical);
// ...while an unsupported tag, or none at all, lands on en.
const english = lookupMaxMind('223.5.5.5', 'en');
assert.deepEqual(lookupMaxMind('223.5.5.5', 'tr'), english);
assert.deepEqual(lookupMaxMind('223.5.5.5'), english);
});
});