diff --git a/CHANGELOG.md b/CHANGELOG.md index c26fcf73..eceb6801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,7 @@ - **api:** fail closed auth gate and secure exports ([b11cbf7](https://github.com/cyclonite69/shadowcheck-web/commit/b11cbf774205eaf9eaca7707d175620399e294de)) - **api:** make v2 filtered manufacturer fields schema-compatible and add regression tests ([2bd4e9f](https://github.com/cyclonite69/shadowcheck-web/commit/2bd4e9fa73a3b63c2f43cf48fc50ffdb0ffe26c9)) - **api:** normalize OBS radio_type values in radio filter expression ([d51065e](https://github.com/cyclonite69/shadowcheck-web/commit/d51065e965a92b76b2058cfbb6866bc759d8200e)) +- **api:** normalize observations endpoint timestamp type to float8 ([519bd4a](https://github.com/cyclonite69/shadowcheck-web/commit/519bd4a41a55233d52e956c8403983593377db5c)) - **api:** optimize filtered network-only queries and notes predicate ([782e59d](https://github.com/cyclonite69/shadowcheck-web/commit/782e59dfd342be44af8b8de63b1244c4dda7b52d)) - **api:** prevent filtered dashboard metrics fallback to zeros ([7a0c94f](https://github.com/cyclonite69/shadowcheck-web/commit/7a0c94fb5dc2c69343f8314b69fb5724641c8ca0)) - **api:** qualify sort columns with ne. alias to prevent 500 ambiguity errors ([dbf0db2](https://github.com/cyclonite69/shadowcheck-web/commit/dbf0db21f582d06af77b08362c47750a37ed9aec)) @@ -234,7 +235,7 @@ - **deps:** bump axios to 1.15.0 ([494b337](https://github.com/cyclonite69/shadowcheck-web/commit/494b3376f5326ae01500b4288153429a5f507b1d)) - **deps:** downgrade @vitejs/plugin-react to 5.0.3 to fix Node 22 build failure ([a539ad9](https://github.com/cyclonite69/shadowcheck-web/commit/a539ad9af823c8467e16e9227523ce0d57612d81)) - **deps:** pin fast-xml-parser to 5.5.8 to restore AWS SDK XML parsing ([0942fac](https://github.com/cyclonite69/shadowcheck-web/commit/0942fac65022e0929fc37a047d44b26d153fbd08)), closes [#xD](https://github.com/cyclonite69/shadowcheck-web/issues/xD) -- **deps:** restore @playwright/test (lost during react-dom merge conflict resolution) ([09474d1](https://github.com/cyclonite69/shadowcheck-web/commit/09474d1490b99fd67ffb2a646f6f3110fb884820)) +- **deps:** restore @playwright/test (lost during react-dom merge conflict resolution) ([aa983f6](https://github.com/cyclonite69/shadowcheck-web/commit/aa983f68dbd30f337017d9b65de17ba50cfef857)) - **dev:** add docker-cli, aws-cli, postgresql-client, curl to Dockerfile builder stage ([23b0399](https://github.com/cyclonite69/shadowcheck-web/commit/23b03991276bafe870e0870fa1a5380b7c549a01)) - **dev:** set REDIS_HOST=redis in dev compose to fix api_dev DNS resolution ([cc631ab](https://github.com/cyclonite69/shadowcheck-web/commit/cc631abe0b824c4becff04c34a4df10c337d69b1)) - disable weather routes to unblock server startup ([c853217](https://github.com/cyclonite69/shadowcheck-web/commit/c85321793ed0af677410ac260dbdf9cd1fa00d89)) @@ -1015,6 +1016,7 @@ - **wigle:** restore tooltip on WiGLE v2 and v3 map point click ([fe66827](https://github.com/cyclonite69/shadowcheck-web/commit/fe66827c63b4a1b01d3d7c65fee45881c8163bb2)) - **wigle:** restore v2/v3 toggle refresh and add encryption filtering ([8016359](https://github.com/cyclonite69/shadowcheck-web/commit/801635931a8d15d215c505de16192552248a4b10)) - **wigle:** restore WiGLE-only and local-match badges on map tooltips ([4b84f0d](https://github.com/cyclonite69/shadowcheck-web/commit/4b84f0d54d2e941b9d2e3cfcc1d09f24b4080060)) +- **wigle:** return numeric epoch times for Explorer WiGLE observations ([e3cdff6](https://github.com/cyclonite69/shadowcheck-web/commit/e3cdff60538888562095d2105da33f149e3cf6ca)) - **wigle:** run import loop in background to prevent HTTP 504 timeouts ([4c7aba3](https://github.com/cyclonite69/shadowcheck-web/commit/4c7aba3bf255ea38b4773c2dae80b1daec84e496)) - **wigle:** seed field data bssids when wigle datasets are off ([3e33420](https://github.com/cyclonite69/shadowcheck-web/commit/3e33420b848c91033d5c7008f51b2b1e648eadbf)) - **wigle:** serve cached detail before live lookup ([e86cb52](https://github.com/cyclonite69/shadowcheck-web/commit/e86cb52ef3506ab988727f67e7d9fe8d349bdb1c)) diff --git a/client/src/components/wigle/mapHandlers.ts b/client/src/components/wigle/mapHandlers.ts index 146a6964..11c904bc 100644 --- a/client/src/components/wigle/mapHandlers.ts +++ b/client/src/components/wigle/mapHandlers.ts @@ -23,6 +23,7 @@ export const attachClickHandlers = ( if (!props || !e.lngLat) return; const netid = String(props.netid || props.bssid || ''); + const featureData: Record = { ...(props as Record), netid: String(props.netid || props.bssid || ''), @@ -32,28 +33,7 @@ export const attachClickHandlers = ( wigle_source: props.wigle_source === 'wigle-v3' ? 'wigle-v3' : 'wigle-v2', }; - // ── INSTRUMENTATION: Stage 1 — feature properties at click ────────────── - console.log('[popup:1:feature]', netid, { - lasttime: featureData.lasttime, - observed_at: featureData.observed_at, - first_seen: featureData.first_seen, - last_seen: featureData.last_seen, - wigle_v3_first_seen: featureData.wigle_v3_first_seen, - wigle_v3_last_seen: featureData.wigle_v3_last_seen, - wigle_source: featureData.wigle_source, - }); - const initialNormalized = normalizeTooltipData(featureData, [e.lngLat.lng, e.lngLat.lat]); - - // ── INSTRUMENTATION: Stage 2 — normalizer output for initial render ────── - console.log('[popup:2:initial-normalized]', netid, { - time: initialNormalized.time, - first_seen: initialNormalized.first_seen, - last_seen: initialNormalized.last_seen, - timespan_days: initialNormalized.timespan_days, - wigle_observation_count: initialNormalized.wigle_observation_count, - }); - const initialHTML = renderNetworkTooltip(initialNormalized); const anchor = getPopupAnchor(map, e.lngLat, initialHTML); @@ -77,15 +57,6 @@ export const attachClickHandlers = ( const { wigle, localLinkage } = pageResponse; - // ── INSTRUMENTATION: Stage 3 — raw enrichment API response ───────── - console.log('[popup:3:api-response]', netid, { - wigle_v3_first_seen: wigle.wigle_v3_first_seen, - wigle_v3_last_seen: wigle.wigle_v3_last_seen, - wigle_v3_observation_count: wigle.wigle_v3_observation_count, - wigle_v2_lasttime: wigle.wigle_v2_lasttime, - wigle_v2_lastupdt: wigle.wigle_v2_lastupdt, - wigle_source: wigle.wigle_source, - }); // Build merged object: WiGLE-truth fields from structured `wigle` section only. // Local linkage maps to the existing flat fields the normalizer already reads. const mergedData: Record = { @@ -148,33 +119,10 @@ export const attachClickHandlers = ( local_last_seen: localLinkage.local_last_seen, }; - // ── INSTRUMENTATION: Stage 4 — mergedData before normalizer ──────── - console.log('[popup:4:merged]', netid, { - lasttime: mergedData.lasttime, - observed_at: mergedData.observed_at, - wigle_v3_first_seen: mergedData.wigle_v3_first_seen, - wigle_v3_last_seen: mergedData.wigle_v3_last_seen, - wigle_v2_lasttime: mergedData.wigle_v2_lasttime, - timespan_days: mergedData.timespan_days, - wigle_v3_observation_count: mergedData.wigle_v3_observation_count, - }); - - const enrichedNormalized = normalizeTooltipData(mergedData); - - // ── INSTRUMENTATION: Stage 5 — normalizer output for enriched render - console.log('[popup:5:enriched-normalized]', netid, { - time: enrichedNormalized.time, - first_seen: enrichedNormalized.first_seen, - last_seen: enrichedNormalized.last_seen, - timespan_days: enrichedNormalized.timespan_days, - wigle_observation_count: enrichedNormalized.wigle_observation_count, - }); - - popup.setHTML(renderNetworkTooltip(enrichedNormalized)); + popup.setHTML(renderNetworkTooltip(normalizeTooltipData(mergedData))); }) .catch((err) => { - // ── INSTRUMENTATION: enrichment request failure ──────────────────── - console.warn('[popup:enrichment-failed]', netid, err); + console.warn('[wigle-tooltip] enrichment fetch failed', err); }); } diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 33dab01b..d4949802 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -233,6 +233,9 @@ List networks with pagination and filtering. Get all observations for a network. +**Response:** `observations[].time` is a JavaScript-safe numeric epoch timestamp in +milliseconds. + ### GET /api/networks/search/:ssid Search by SSID. diff --git a/docs/ai/decisions/20260707_vite_build_mode_nodejs_env_bleed.md b/docs/ai/decisions/20260707_vite_build_mode_nodejs_env_bleed.md index 93f05ce3..76cb0ee3 100644 --- a/docs/ai/decisions/20260707_vite_build_mode_nodejs_env_bleed.md +++ b/docs/ai/decisions/20260707_vite_build_mode_nodejs_env_bleed.md @@ -26,7 +26,7 @@ by `process.env.NODE_ENV` in the Vite process itself at build time. From Vite 8' (`node_modules/vite/dist/node/chunks/node.js`): ```javascript -const isProduction = process.env.NODE_ENV === "production"; +const isProduction = process.env.NODE_ENV === 'production'; ``` `isProduction` controls the `DEV` and `PROD` values baked into the bundle. @@ -51,10 +51,10 @@ console.log('[ENV_PROBE]', { Two builds, different `NODE_ENV`, identical `--mode production`: -| Build command | Compiled output | -|---|---| +| Build command | Compiled output | +| --------------------------------------------------- | ---------------------------------------------- | | `NODE_ENV=development vite build --mode production` | `{DEV: true, PROD: false, MODE: "production"}` | -| `NODE_ENV=production vite build --mode production` | `{DEV: false, PROD: true, MODE: "production"}` | +| `NODE_ENV=production vite build --mode production` | `{DEV: false, PROD: true, MODE: "production"}` | The second build also correctly eliminated the `window.__wigleHandleUnclustered` assignment from the bundle. The first did not. @@ -77,7 +77,7 @@ eliminated and the assignment became a bare statement in every production Docker ```javascript // Minified output (production container) — NO guard, NO if() -window.__wigleHandleUnclustered=i; +window.__wigleHandleUnclustered = i; ``` `window.__wigleHandleUnclustered` has been exposed on the global window object in every @@ -90,7 +90,7 @@ const isDev = import.meta.env.DEV; function shouldLog(level: LogLevel) { if (levelOrder[level] < minLevel) return false; - if (!isDev && (level === 'debug' || level === 'info')) return false; // ← suppressed in prod + if (!isDev && (level === 'debug' || level === 'info')) return false; // ← suppressed in prod return true; } ``` @@ -109,8 +109,8 @@ Note: `debug` logs were already suppressed regardless of `DEV` — the first gua Both use a double gate: ```typescript -import.meta.env.DEV && import.meta.env.VITE_SIBLING_TOPOLOGY_DEBUG === 'true' -import.meta.env.DEV && import.meta.env.VITE_SIBLING_DEBUG === 'true' +import.meta.env.DEV && import.meta.env.VITE_SIBLING_TOPOLOGY_DEBUG === 'true'; +import.meta.env.DEV && import.meta.env.VITE_SIBLING_DEBUG === 'true'; ``` The second operand (`=== 'true'`) evaluated to `false` because neither env var is set by @@ -139,10 +139,10 @@ not independently confirm which value was substituted for `DEV`. A temporary `console.log` of `import.meta.env.DEV/PROD/MODE` was added to `mapHandlers.ts` and two builds were compared: -| Build command | Compiled output | -|---|---| +| Build command | Compiled output | +| --------------------------------------------------- | ---------------------------------------------- | | `NODE_ENV=development vite build --mode production` | `{DEV: true, PROD: false, MODE: "production"}` | -| `NODE_ENV=production vite build --mode production` | `{DEV: false, PROD: true, MODE: "production"}` | +| `NODE_ENV=production vite build --mode production` | `{DEV: false, PROD: true, MODE: "production"}` | The second build also eliminated the `window.__wigleHandleUnclustered` assignment from the bundle. The first did not. @@ -156,9 +156,11 @@ can be updated from "evidence indicates" to "confirmed by rebuilt artifact." **4. Vite 8 source confirms the mechanism.** `node_modules/vite/dist/node/chunks/node.js` line ~34582: + ```javascript -const isProduction = process.env.NODE_ENV === "production"; +const isProduction = process.env.NODE_ENV === 'production'; ``` + `isProduction` controls the `DEV`/`PROD` values baked into the bundle. `--mode production` sets `MODE` and loads `.env.production` files but does not set `process.env.NODE_ENV`. These are independent concerns in Vite 8's internals. @@ -208,12 +210,12 @@ invocation only, not to the entire stage. ## Blast radius of the fix -| Location | Current behavior (broken) | Fixed behavior | Impact | -|---|---|---|---| -| `mapHandlers.ts` | `window.__wigleHandleUnclustered` exposed unconditionally | Eliminated from bundle (dead code) | Seam removed from production | -| `clientLogger.ts` | `info` logs fire in production console | `info` logs suppressed in production | Production console quieter | -| `siblingTopologyDebug.ts` | No change (double-gated, already off) | No change | None | -| `NetworkExplorerSection.tsx` | No change (double-gated, already off) | No change | None | +| Location | Current behavior (broken) | Fixed behavior | Impact | +| ---------------------------- | --------------------------------------------------------- | ------------------------------------ | ---------------------------- | +| `mapHandlers.ts` | `window.__wigleHandleUnclustered` exposed unconditionally | Eliminated from bundle (dead code) | Seam removed from production | +| `clientLogger.ts` | `info` logs fire in production console | `info` logs suppressed in production | Production console quieter | +| `siblingTopologyDebug.ts` | No change (double-gated, already off) | No change | None | +| `NetworkExplorerSection.tsx` | No change (double-gated, already off) | No change | None | The logger change is a behavioral change that ops/dev users may notice (production browser console loses `[INFO]` output). This is the intended behavior that has never correctly diff --git a/docs/metrics/lines-of-code.md b/docs/metrics/lines-of-code.md index 469ab2a0..758d20eb 100644 --- a/docs/metrics/lines-of-code.md +++ b/docs/metrics/lines-of-code.md @@ -1,15 +1,15 @@ # LOC Metrics -_Generated: Wed Jul 8 03:16:41 PM EDT 2026 | SHA: 09474d14_ +_Generated: Wed Jul 8 05:58:18 PM EDT 2026 | SHA: 2ff30228_ -| cloc | github.com/AlDanial/cloc v 2.06 T=3.99 s (462.8 files/s, 161808.9 lines/s) | +| cloc | github.com/AlDanial/cloc v 2.06 T=4.35 s (425.1 files/s, 148577.8 lines/s) | | ---- | -------------------------------------------------------------------------- | | Language | files | blank | comment | code | | :--------- | -------: | -------: | -------: | -------: | | SQL | 340 | 4533 | 6245 | 414867 | -| TypeScript | 1474 | 24231 | 8284 | 182592 | +| TypeScript | 1475 | 24245 | 8301 | 182694 | | JavaScript | 32 | 486 | 237 | 3908 | | CSS | 1 | 47 | 27 | 276 | | -------- | -------- | -------- | -------- | -------- | -| SUM: | 1847 | 29297 | 14793 | 601643 | +| SUM: | 1848 | 29311 | 14810 | 601745 | diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c24e8e43..3ecc7c17 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1175,8 +1175,9 @@ components: signal: type: integer time: - type: integer - format: int64 + type: number + format: double + description: Epoch timestamp in milliseconds, returned as a JSON number. acc: type: number alt: diff --git a/server/src/api/routes/v1/wigle/database.ts b/server/src/api/routes/v1/wigle/database.ts index 16c38631..d898a10c 100644 --- a/server/src/api/routes/v1/wigle/database.ts +++ b/server/src/api/routes/v1/wigle/database.ts @@ -70,15 +70,15 @@ const validateWigleNetworksQuery = validateQuery({ * the wigle_networks_enriched view for v2-only imports. */ router.get( - '/page/network/:netid', + '/page/network/:bssid', macParamMiddleware, asyncHandler(async (req: Request, res: Response) => { - const { netid } = req.params; + const { bssid } = req.params; // Try MV first (single-row read); fall back to live 4-query fan-out if MV // is unavailable (pre-migration deployment) or returns no row. - let network = await wigleService.getWiglePageNetworkFromMv(netid); + let network = await wigleService.getWiglePageNetworkFromMv(bssid); if (!network) { - network = await wigleService.getWiglePageNetwork(netid); + network = await wigleService.getWiglePageNetwork(bssid); } if (!network) { return res.status(404).json({ error: 'Network not found in WiGLE database' }); diff --git a/server/src/services/observationService.ts b/server/src/services/observationService.ts index b30805b0..1b58de4c 100644 --- a/server/src/services/observationService.ts +++ b/server/src/services/observationService.ts @@ -32,7 +32,7 @@ export async function getObservationsByBSSID( const { rows } = await query( `SELECT ROW_NUMBER() OVER (ORDER BY o.time) as id, o.bssid, COALESCE(NULLIF(o.ssid, ''), '(hidden)') as ssid, o.radio_type as type, - o.lat, o.lon, o.level as signal, EXTRACT(EPOCH FROM o.time)::BIGINT * 1000 as time, + o.lat, o.lon, o.level as signal, (EXTRACT(EPOCH FROM o.time) * 1000)::float8 as time, COALESCE(o.accuracy, 3.79) as acc, o.altitude as alt, gc.address as geocoded_address, gc.city as geocoded_city, gc.state as geocoded_state, gc.poi_name as geocoded_poi_name, @@ -92,7 +92,7 @@ export async function getWigleObservationsByBSSID(bssid: string): Promise FROM app.wigle_v3_observations w WHERE UPPER(w.netid) = $1 AND w.latitude IS NOT NULL AND w.longitude IS NOT NULL ) - SELECT we.bssid, we.lat, we.lon, EXTRACT(EPOCH FROM we.time) * 1000 as time, + SELECT we.bssid, we.lat, we.lon, (EXTRACT(EPOCH FROM we.time) * 1000)::float8 as time, we.level, we.ssid, we.frequency, we.channel, we.encryption, we.altitude, we.accuracy, we.is_matched, CASE @@ -149,7 +149,7 @@ export async function getWigleObservationsBatch(bssids: string[]): Promise npx playwright test tests/e2e/wigleTimestampDiag.spec.ts --reporter=list + */ + +import { test, expect } from '@playwright/test'; + +// Known dates matching the wigleTooltipTemporal mock — deterministic +const FIRST_SEEN = '2023-02-17T08:00:00.000Z'; +const LAST_SEEN = '2026-02-17T08:00:00.000Z'; +const MOCK_BSSID = 'A4:43:8C:64:2A:43'; +const MOCK_LNG = -83.696; +const MOCK_LAT = 43.023; + +const MOCK_NETWORK_RESPONSE = { + wigle: { + bssid: MOCK_BSSID, + ssid: 'DIAG-V3', + name: 'DIAG-V3', + type: 'W', + encryption: '[WPA2-PSK-CCMP][ESS]', + channel: 6, + frequency: 2437, + qos: 5, + comment: null, + wigle_source: 'wigle-v3', + wigle_v2_firsttime: null, + wigle_v2_lasttime: null, + wigle_v2_lastupdt: null, + wigle_v2_trilat: null, + wigle_v2_trilong: null, + wigle_v2_city: null, + wigle_v2_region: null, + wigle_v2_road: null, + wigle_v2_housenumber: null, + has_wigle_v2_record: false, + wigle_v3_first_seen: FIRST_SEEN, + wigle_v3_last_seen: LAST_SEEN, + wigle_v3_observation_count: 42, + wigle_v3_centroid_lat: MOCK_LAT, + wigle_v3_centroid_lon: MOCK_LNG, + wigle_v3_spread_m: 50, + has_wigle_v3_observations: true, + display_lat: MOCK_LAT, + display_lon: MOCK_LNG, + display_coordinate_source: 'wigle-v3-centroid', + manufacturer: 'Cisco', + public_nonstationary_flag: false, + public_ssid_variant_flag: false, + wigle_precision_warning: false, + recent_ssid: 'DIAG-V3', + recent_channel: 6, + recent_frequency: 2437, + recent_accuracy: 8, + geocoded_address: '1 Diag St, Test, MI', + }, + localLinkage: { + has_local_match: false, + local_observation_count: 0, + local_first_seen: null, + local_last_seen: null, + }, +}; + +test('capture popup pipeline boundary logs for a v3 network', async ({ page }) => { + // Collect every [popup:*] console message in order + const logs: { tag: string; traceId: string; data: any }[] = []; + + page.on('console', (msg) => { + const text = msg.text(); + if (!text.includes('[popup:')) return; + // Playwright serialises structured console.log args — grab them + const args = msg.args(); + // tag is first arg, traceId is second, data object is third + Promise.all(args.map((a) => a.jsonValue().catch(() => String(a)))).then((vals) => { + logs.push({ + tag: String(vals[0] ?? ''), + traceId: String(vals[1] ?? ''), + data: vals[2] ?? {}, + }); + }); + }); + + page.on('pageerror', (err) => console.error('[pageerror]', err.message)); + + // Intercept enrichment so we get deterministic timestamps regardless of DB state + await page.route('**/api/wigle/page/network/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(MOCK_NETWORK_RESPONSE), + }); + }); + + await page.goto('/wigle'); + + await page.waitForFunction(() => !!(window as any).__wigleMapInstance, { timeout: 15000 }); + await page.waitForFunction(() => (window as any).__wigleMapInstance?.isStyleLoaded?.(), { + timeout: 15000, + }); + await page.waitForFunction(() => typeof (window as any).__wigleHandleUnclustered === 'function', { + timeout: 10000, + }); + + // Fire the handler with a synthetic v3 feature — no canvas click, no zoom/cluster dependency + await page.evaluate( + ({ lng, lat, bssid }) => { + (window as any).__wigleHandleUnclustered({ + features: [ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [lng, lat] }, + properties: { + bssid, + netid: bssid, + ssid: 'DIAG-V3', + trilat: lat, + trilong: lng, + wigle_source: 'wigle-v3', + // No lasttime / observed_at on the feature — simulates the real v3 case + }, + }, + ], + lngLat: { lng, lat }, + }); + }, + { lng: MOCK_LNG, lat: MOCK_LAT, bssid: MOCK_BSSID } + ); + + // Wait for popup to appear + await expect(page.locator('.sc-popup')).toBeVisible({ timeout: 8000 }); + + // Wait for enrichment: 42 obs count only appears after mock resolves + await expect(page.locator('.sc-popup')).toContainText('42', { timeout: 8000 }); + + // Let async console.log promises resolve + await page.waitForTimeout(600); + + // Expand the Timestamps
section if present + const details = page.locator('.sc-popup details'); + if (await details.isVisible({ timeout: 2000 }).catch(() => false)) { + await details.click(); + await page.waitForTimeout(200); + } + + // Read the rendered SEEN cell text from the DOM + const seenDomText = await page.evaluate(() => { + const popup = document.querySelector('.sc-popup'); + if (!popup) return null; + for (const div of Array.from(popup.querySelectorAll('div'))) { + if (div.textContent?.trim() === 'Seen') { + const sibling = div.parentElement?.querySelector('div:last-child'); + return sibling?.textContent?.trim() ?? null; + } + } + return null; + }); + + console.log('\n=== DOM RESULT ==='); + console.log('SEEN cell text in rendered popup:', JSON.stringify(seenDomText)); + + const enrichmentFailed = logs.find( + (l) => + l.tag === '[popup:enrichment-failed]' || + (typeof l.tag === 'string' && l.tag.includes('enrichment')) + ); + + if (enrichmentFailed) { + console.log('ENRICHMENT FAILED:', enrichmentFailed); + } + + // The SEEN cell must contain a real timestamp (not "—") — enrichment reached the server + // and returned valid wigle_v3_last_seen data. + expect(seenDomText, 'SEEN cell should contain a timestamp, not "—"').not.toBe('—'); + expect(seenDomText, 'SEEN cell should not be null').not.toBeNull(); + expect(enrichmentFailed, 'Enrichment must not fail').toBeUndefined(); +}); diff --git a/tests/integration/api/v1/observations.test.ts b/tests/integration/api/v1/observations.test.ts index 897139ff..19c35988 100644 --- a/tests/integration/api/v1/observations.test.ts +++ b/tests/integration/api/v1/observations.test.ts @@ -73,6 +73,7 @@ describe('Observations API v1', () => { expect(res.body.ok).toBe(true); expect(res.body.bssid).toBe(bssid); expect(res.body.observations).toEqual(mockObservations); + expect(typeof res.body.observations[0].time).toBe('number'); expect(res.body.home).toEqual(mockHome); }); diff --git a/tests/unit/observationService.test.ts b/tests/unit/observationService.test.ts index 17d9912e..4c450440 100644 --- a/tests/unit/observationService.test.ts +++ b/tests/unit/observationService.test.ts @@ -83,6 +83,8 @@ describe('Observation Service', () => { const result = await getObservationsByBSSID('AA:BB:CC:DD:EE:FF', -122.4194, 37.7749); expect(result).toEqual(mockRows); + const sql = (query as jest.Mock).mock.calls[0][0]; + expect(sql).toContain('(EXTRACT(EPOCH FROM o.time) * 1000)::float8 as time'); expect(query).toHaveBeenCalledWith(expect.stringContaining('ST_Distance'), [ -122.4194, 37.7749,