diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index b3ea471e..e59c86bb 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -195,6 +195,8 @@ func routeDescriptions() map[string]routeMeta { {Name: "bbox", Description: "Bounding box as minLat,minLon,maxLat,maxLon", Type: "string", Required: true}, {Name: "z", Description: "Leaflet zoom level; sets the hex resolution", Type: "integer"}, {Name: "days", Description: "Look-back window in days (1-30, default 7)", Type: "integer"}, + {Name: "rx", Description: "Only this companion's samples", Type: "string"}, + {Name: "mine", Description: "1 = only samples of companions linked to the logged-in user (browser session; 401 without one)", Type: "string"}, }}, "GET /api/rx-coverage": {Summary: "Mobile RX coverage hex grid", Description: "GeoJSON hex cells where mobile clients directly heard nodes (client_receptions): count, best SNR and the nodes heard per cell. With gaps=1 the payload also carries a `gaps` member: cells a companion drove through (moving client_rf_samples) with no reception in the same window, each with its sample count, plus `gaps_truncated` when capped. Gaps are only returned without a node filter and with clientRfSamples on; they mean nothing was received in this period, not that there is no coverage. Gated on clientRxCoverage.", Tag: "coverage", QueryParams: []paramMeta{ diff --git a/cmd/server/rf_noise.go b/cmd/server/rf_noise.go index ef49f8f1..a90bef2e 100644 --- a/cmd/server/rf_noise.go +++ b/cmd/server/rf_noise.go @@ -144,10 +144,16 @@ func (s *Server) requireClientRfSamples(w http.ResponseWriter, r *http.Request) } // queryRfNoiseRows returns raw RF-environment samples within a bbox, over a -// time window (days; 0 = all time). Read-only (server RO connection). -func (s *Server) queryRfNoiseRows(days int, b bbox) ([]rfNoiseRow, error) { +// time window (days; 0 = all time), optionally narrowed to one companion (rx) +// or a set of companions (rxIn, see queryCoverageFiltered). Read-only (server +// RO connection). +func (s *Server) queryRfNoiseRows(rx string, rxIn []string, days int, b bbox) ([]rfNoiseRow, error) { + if rxIn != nil && len(rxIn) == 0 { + return []rfNoiseRow{}, nil + } where := []string{"lat BETWEEN ? AND ?", "lon BETWEEN ? AND ?", "noise_floor IS NOT NULL"} args := []interface{}{b.MinLat, b.MaxLat, b.MinLon, b.MaxLon} + where, args = appendRxFilter(where, args, "rx_pubkey", rx, rxIn) if days > 0 { since := time.Now().UTC().AddDate(0, 0, -days).Format(time.RFC3339) where = append(where, "sampled_at >= ?") @@ -174,11 +180,19 @@ func (s *Server) queryRfNoiseRows(days int, b bbox) ([]rfNoiseRow, error) { } // handleRfNoise serves the RF noise-floor map layer as GeoJSON hex cells, -// over a time window. Mirrors handleRxCoverage's parameter handling. +// over a time window. Mirrors handleRxCoverage's parameter handling, +// including ?rx= and ?mine=1. func (s *Server) handleRfNoise(w http.ResponseWriter, r *http.Request) { if !s.requireClientRfSamples(w, r) { return } + var mine []string + if r.URL.Query().Get("mine") == "1" { + var ok bool + if mine, ok = s.myCompanionPubkeys(w, r); !ok { + return + } + } b, ok := parseBBox(r.URL.Query().Get("bbox")) if !ok { http.Error(w, "bbox required as minLat,minLon,maxLat,maxLon", http.StatusBadRequest) @@ -190,7 +204,7 @@ func (s *Server) handleRfNoise(w http.ResponseWriter, r *http.Request) { } days := clampDays(atoiDefault(r.URL.Query().Get("days"), 7)) z, _ := strconv.Atoi(r.URL.Query().Get("z")) - rows, err := s.queryRfNoiseRows(days, b) + rows, err := s.queryRfNoiseRows(r.URL.Query().Get("rx"), mine, days, b) if err != nil { http.Error(w, "query failed", http.StatusInternalServerError) return diff --git a/cmd/server/rf_noise_mine_test.go b/cmd/server/rf_noise_mine_test.go new file mode 100644 index 00000000..2f07666d --- /dev/null +++ b/cmd/server/rf_noise_mine_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// The noise layer narrows to one companion (?rx=) or to the caller's linked +// companions (?mine=1) the same way the signal layer does. +func TestRfNoiseRxAndMine(t *testing.T) { + f := newAuthFixture(t) + f.srv.db = seedRfNoiseDB(t) + f.srv.cfg.ClientRfSamples = &ClientRfSamplesConfig{Enabled: true} + f.router.HandleFunc("/api/rf-noise", f.srv.handleRfNoise).Methods("GET") + mine, other := pubHex(companionKey), pubHex(otherKey) + now := time.Now().UTC().Format(time.RFC3339) + for i, pk := range []string{mine, other, other} { + mustExecDB(t, f.srv.db, fmt.Sprintf(`INSERT INTO client_rf_samples (rx_pubkey,sampled_at,ingested_at,lat,lon,stationary,uptime_secs,noise_floor) + VALUES ('%s','%s','t',%f,3.72,0,100,-110)`, pk, now, 51.05+float64(i)*0.05)) + } + const q = "/api/rf-noise?bbox=50,3,52,4&z=10" + get := func(path string, mods ...reqMod) *httptest.ResponseRecorder { return f.do("GET", path, nil, mods...) } + all := get(q).Body.String() + empty := get(q + "&rx=" + strings.Repeat("0", 64)).Body.String() + if strings.Count(all, `"Feature"`) != 3 || strings.Count(empty, `"Feature"`) != 0 { + t.Fatalf("rx= does not narrow: all=%s empty=%s", all, empty) + } + if got := get(q + "&rx=" + strings.ToUpper(other)).Body.String(); strings.Count(got, `"Feature"`) != 2 { + t.Fatalf("rx= (upper case) = %s, want its 2 cells", got) + } + + expectStatus(t, get(q+"&mine=1"), http.StatusUnauthorized) + alice := f.registerAndActivate(t, "alice@example.org", "Alice", pw) + w := get(q+"&mine=1", as(alice)) + expectStatus(t, w, http.StatusOK) + if w.Body.String() != empty { + t.Fatalf("nothing linked: got %s, want the empty collection", w.Body.String()) + } + + tok := f.deviceToken(t, "alice@example.org", pw, "Pixel").Token + expectStatus(t, f.linkCompanion(t, tok, companionKey, "Car"), http.StatusOK) + w = get(q+"&mine=1", as(alice)) + expectStatus(t, w, http.StatusOK) + if w.Body.String() != get(q+"&rx="+mine).Body.String() || w.Body.String() == all { + t.Fatalf("mine=1 is not the linked companion's noise: %s", w.Body.String()) + } + expectStatus(t, get(q+"&mine=1", bearer(tok)), http.StatusForbidden) +} + +func TestRfNoiseMineWithoutUserManagement(t *testing.T) { + srv := &Server{db: seedRfNoiseDB(t), cfg: &Config{ClientRfSamples: &ClientRfSamplesConfig{Enabled: true}}} + w := httptest.NewRecorder() + srv.handleRfNoise(w, httptest.NewRequest("GET", "/api/rf-noise?bbox=50,3,52,4&mine=1", nil)) + if w.Code != http.StatusNotFound { + t.Fatalf("mine=1 with user management off = %d, want 404", w.Code) + } +} diff --git a/cmd/server/rx_dashboard.go b/cmd/server/rx_dashboard.go index eaef831d..845c820e 100644 --- a/cmd/server/rx_dashboard.go +++ b/cmd/server/rx_dashboard.go @@ -126,16 +126,7 @@ func (s *Server) queryCoverageFiltered(node, rx string, rxIn []string, days int, args = append(args, c) } } - if rx != "" { - where = append(where, "rx_pubkey = ?") - args = append(args, strings.ToLower(rx)) - } - if len(rxIn) > 0 { - where = append(where, "rx_pubkey IN ("+sqlPlaceholders(len(rxIn))+")") - for _, pk := range rxIn { - args = append(args, pk) - } - } + where, args = appendRxFilter(where, args, "rx_pubkey", rx, rxIn) if days > 0 { since := time.Now().UTC().AddDate(0, 0, -days).Format(time.RFC3339) where = append(where, "rx_at >= ?") @@ -149,6 +140,23 @@ func (s *Server) queryCoverageFiltered(node, rx string, rxIn []string, days int, return scanCoverageRows(rows) } +// appendRxFilter narrows a client_* table query to one companion (rx) and/or +// to a set of companions (rxIn, lowercase; see myCompanionPubkeys). col is +// the table's rx_pubkey column. Callers return early on an empty non-nil rxIn. +func appendRxFilter(where []string, args []interface{}, col, rx string, rxIn []string) ([]string, []interface{}) { + if rx != "" { + where = append(where, col+" = ?") + args = append(args, strings.ToLower(rx)) + } + if len(rxIn) > 0 { + where = append(where, col+" IN ("+sqlPlaceholders(len(rxIn))+")") + for _, pk := range rxIn { + args = append(args, pk) + } + } + return where, args +} + // handleRxCoverage serves global (or per-observer via ?rx=) coverage as GeoJSON // hexbins, over a time window. ?node= also works (same as the per-node endpoint). // requireClientRxCoverage writes a 404 and returns false when the opt-in diff --git a/public/rx-coverage.js b/public/rx-coverage.js index 9ecb73ce..2bdea99b 100644 --- a/public/rx-coverage.js +++ b/public/rx-coverage.js @@ -19,11 +19,11 @@ var gapRenderer = null; function gapsAvailable() { return !!window.MC_CLIENT_RF_SAMPLES; } - // mine: "My coverage": the signal layer only shows coverage collected by - // companions linked to the logged-in user (/api/rx-coverage?mine=1). + // mine: "My coverage": every layer only shows data collected by companions + // linked to the logged-in user (?mine=1). var mine = false; - // signalSeq numbers signal-layer requests; only the newest reply is drawn. - var signalSeq = 0; + // drawSeq numbers layer requests; only the newest reply is drawn. + var drawSeq = 0; function cssColor(varName) { try { return getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || '#888'; } @@ -51,13 +51,14 @@ ' title="Only coverage collected by companions linked to your account">My coverage'; } - // coverageUrl builds the signal-layer request. - function coverageUrl(bbox, z, d, rx, mineOn) { - return '/api/rx-coverage?bbox=' + bbox + '&z=' + z + '&days=' + d + + // coverageUrl builds a layer request (path = the layer's endpoint) with the + // shared filters: time window, selected observer and "My coverage". + function coverageUrl(path, bbox, z, d, rx, mineOn) { + return path + '?bbox=' + bbox + '&z=' + z + '&days=' + d + (rx ? '&rx=' + encodeURIComponent(rx) : '') + (mineOn ? '&mine=1' : ''); } - // coverageReply judges a signal-layer reply. Only the newest request counts + // coverageReply judges a layer reply. Only the newest request counts // (an older, unfiltered reply must not overwrite "My coverage"); a 401/403 // to mine=1 means the session is gone, which is not "no coverage". function coverageReply(seq, latest, status, mineSent) { @@ -66,6 +67,12 @@ return status >= 200 && status < 300 ? 'draw' : 'error'; } + // freshBody resolves to the parsed body, or to null when a newer request + // started while it was still arriving: coverageReply only saw the headers. + function freshBody(seq, latest, body) { + return body.then(function (fc) { return seq === latest() ? fc : null; }); + } + // authUser is the logged-in user, or null (accounts off, logged out, or // auth.js not loaded). function authUser(auth) { @@ -238,23 +245,31 @@ ''; } - function drawSignalLayer(bbox) { - // Until auth.js has answered, mine is never sent (it would answer 401). - var mineSent = mine && !!authUser(window.CSAuth); - // Gaps need a single companion's track, so not with mine (the server omits them too). - var wantGaps = gapsAvailable() && showGaps && !mineSent; - var url = coverageUrl(bbox, map.getZoom(), days, selectedRx, mineSent) + (wantGaps ? '&gaps=1' : ''); - var seq = ++signalSeq; - fetch(url).then(function (r) { - var verdict = coverageReply(seq, signalSeq, r.status, mineSent); + // mineSent: until auth.js has answered, mine is never sent (it would answer 401). + function mineSent() { return mine && !!authUser(window.CSAuth); } + + // fetchLayer requests one layer's cells with the shared filters. It resolves + // to the FeatureCollection, or to null when request seq was superseded or + // mine=1 was refused (the toggle is then cleared and the map redrawn). + function fetchLayer(path, bbox, extra, seq) { + var sentMine = mineSent(); + return fetch(coverageUrl(path, bbox, map.getZoom(), days, selectedRx, sentMine) + extra).then(function (r) { + var verdict = coverageReply(seq, drawSeq, r.status, sentMine); if (verdict === 'ignore') return null; if (verdict === 'mine-refused') { if (!destroyed && map) { mine = false; renderMine(); drawCoverage(); syncHash(); } return null; } if (verdict === 'error') throw new Error('HTTP ' + r.status); - return r.json(); - }).then(function (fc) { + return freshBody(seq, function () { return drawSeq; }, r.json()); + }); + } + + function drawSignalLayer(bbox) { + // Gaps need a single companion's track, so not with mine (the server omits them too). + var wantGaps = gapsAvailable() && showGaps && !mineSent(); + var seq = ++drawSeq; + fetchLayer('/api/rx-coverage', bbox, wantGaps ? '&gaps=1' : '', seq).then(function (fc) { if (!fc || destroyed || !covLayer || layer !== 'signal') return; covLayer.clearLayers(); setNoiseEmpty(false); @@ -276,14 +291,14 @@ }).catch(function (e) { console.warn('rx-coverage: coverage fetch failed', e); // #1: never leave stale hexes from a previous layer/view on screen. - if (!destroyed && covLayer && layer === 'signal' && seq === signalSeq) covLayer.clearLayers(); + if (!destroyed && covLayer && layer === 'signal' && seq === drawSeq) covLayer.clearLayers(); }); } function drawNoiseLayer(bbox) { - var url = '/api/rf-noise?bbox=' + bbox + '&z=' + map.getZoom() + '&days=' + days; - fetch(url).then(function (r) { return r.json(); }).then(function (fc) { - if (destroyed || !covLayer || layer !== 'noise') return; + var seq = ++drawSeq; + fetchLayer('/api/rf-noise', bbox, '', seq).then(function (fc) { + if (!fc || destroyed || !covLayer || layer !== 'noise') return; covLayer.clearLayers(); var features = fc.features || []; setNoiseEmpty(features.length === 0); @@ -298,7 +313,7 @@ // #1/#2: a slow/failing request must not leave the map showing the // other layer's hexes under the noise legend, nor sit there blank and // unlabelled indistinguishable from "no samples in this view". - if (destroyed || !covLayer || layer !== 'noise') return; + if (destroyed || !covLayer || layer !== 'noise' || seq !== drawSeq) return; covLayer.clearLayers(); setNoiseEmpty(true, 'Could not load noise samples. Pan or zoom to retry.'); }); @@ -614,6 +629,6 @@ } if (window.addEventListener) window.addEventListener('cs-auth-changed', onAuthChanged); - window.CSRxCoverage = { _test: { coverageUrl: coverageUrl, mineBtnHtml: mineBtnHtml, authUser: authUser, coverageReply: coverageReply } }; + window.CSRxCoverage = { _test: { coverageUrl: coverageUrl, mineBtnHtml: mineBtnHtml, authUser: authUser, coverageReply: coverageReply, freshBody: freshBody } }; registerPage('rx-coverage', { init: init, destroy: destroy }); })(); diff --git a/tests/e2e/test-rx-coverage-noise-e2e.js b/tests/e2e/test-rx-coverage-noise-e2e.js index e0a02a53..3caebc5e 100644 --- a/tests/e2e/test-rx-coverage-noise-e2e.js +++ b/tests/e2e/test-rx-coverage-noise-e2e.js @@ -2,8 +2,9 @@ 'use strict'; // RF noise-floor layer on the Mobile RX coverage page: the Signal/Noise toggle // exists only when clientRfSamples is on, switching draws /api/rf-noise cells -// with the noise legend, the layer is deep-linkable, and an empty answer says -// so instead of leaving a blank map. +// with the noise legend, the layer is deep-linkable, an empty answer says +// so instead of leaving a blank map, and the observer filter and "My coverage" +// narrow the noise layer like the signal layer. const assert = require('assert'); const { chromium } = require('playwright'); const BASE = process.env.BASE_URL || 'http://localhost:3000'; @@ -20,15 +21,18 @@ async function step(name, fn) { catch (e) { failed++; console.log(' ✗ ' + name + ': ' + e.message); } } -async function openPage(browser, { rfSamples, noiseFeatures, hash }) { +async function openPage(browser, { rfSamples, noiseFeatures, hash, user }) { const page = await browser.newPage(); const errors = []; page.on('pageerror', e => errors.push(e.message)); const noiseRequests = []; await page.route('**/api/config/client', async route => { const response = await route.fetch(); - await route.fulfill({ json: { ...await response.json(), clientRxCoverage: true, clientRfSamples: rfSamples } }); + const cfg = { ...await response.json(), clientRxCoverage: true, clientRfSamples: rfSamples }; + if (user) cfg.userManagement = { enabled: true }; + await route.fulfill({ json: cfg }); }); + if (user) await page.route('**/api/auth/me', route => route.fulfill({ json: user })); await page.route('**/api/config/map', route => route.fulfill({ json: { center: [51, 4], zoom: 9 } })); await page.route('**/api/rx-leaderboard?*', route => route.fulfill({ json: { observers: [] } })); await page.route('**/api/rx-coverage?*', route => route.fulfill({ json: { type: 'FeatureCollection', features: [] } })); @@ -83,6 +87,28 @@ async function openPage(browser, { rfSamples, noiseFeatures, hash }) { assert.deepStrictEqual(errors, []); await page.close(); }); + + await step('the noise layer sends the observer filter and My coverage', async () => { + const rx = 'ab'.repeat(32); + const { page, errors, noiseRequests } = await openPage(browser, { + rfSamples: true, noiseFeatures: [], hash: '?layer=noise&rx=' + rx, + user: { id: 1, email: 'alice@example.org', displayName: 'Alice', role: 'user', csrfToken: 't' }, + }); + await page.waitForSelector('#rxMineBar button[data-mine]', { timeout: 10000 }); + for (let i = 0; i < 50 && !noiseRequests.length; i++) await page.waitForTimeout(100); + assert.ok(noiseRequests.length >= 1, 'a ?layer=noise link must fetch the noise layer'); + assert.strictEqual(new URL(noiseRequests[noiseRequests.length - 1]).searchParams.get('rx'), rx, 'rx is sent to /api/rf-noise'); + const before = noiseRequests.length; + await page.click('#rxMineBar button[data-mine]'); + await page.waitForFunction(() => /[?&]mine=1/.test(location.hash), null, { timeout: 10000 }); + for (let i = 0; i < 50 && noiseRequests.length === before; i++) await page.waitForTimeout(100); + assert.ok(noiseRequests.length > before, 'toggling My coverage refetches the noise layer'); + const last = new URL(noiseRequests[noiseRequests.length - 1]).searchParams; + assert.strictEqual(last.get('mine'), '1', 'mine=1 is sent to /api/rf-noise'); + assert.strictEqual(last.get('rx'), rx, 'the observer filter stays on'); + assert.deepStrictEqual(errors, []); + await page.close(); + }); } finally { await browser.close(); } diff --git a/tests/unit/test-rx-coverage-mine.js b/tests/unit/test-rx-coverage-mine.js index 9f344de7..7b796dc0 100644 --- a/tests/unit/test-rx-coverage-mine.js +++ b/tests/unit/test-rx-coverage-mine.js @@ -2,8 +2,9 @@ const REPO_ROOT = require('path').resolve(__dirname, '..', '..'); // "My coverage" toggle (docs/specs/2026-10-08-companion-linking-design.md, // Coverage attribution): shown to logged-in users only, it adds mine=1 to the -// signal-layer request. The real public/rx-coverage.js is loaded in a vm, with -// a window that has no addEventListener, like test-rx-coverage-config-race.js. +// request of every layer (signal and noise). The real public/rx-coverage.js +// is loaded in a vm, with a window that has no addEventListener, like +// test-rx-coverage-config-race.js. const assert = require('assert'); const fs = require('fs'); const path = require('path'); @@ -26,9 +27,10 @@ const t = sandbox.window.CSRxCoverage && sandbox.window.CSRxCoverage._test; assert.ok(t, 'rx-coverage.js should expose window.CSRxCoverage._test'); // The request URL: mine=1 only with the toggle on, after the rx filter. -assert.strictEqual(t.coverageUrl('1,2,3,4', 9, 7, '', false), '/api/rx-coverage?bbox=1,2,3,4&z=9&days=7'); -assert.strictEqual(t.coverageUrl('1,2,3,4', 9, 7, '', true), '/api/rx-coverage?bbox=1,2,3,4&z=9&days=7&mine=1'); -assert.strictEqual(t.coverageUrl('1,2,3,4', 9, 30, 'ab cd', true), '/api/rx-coverage?bbox=1,2,3,4&z=9&days=30&rx=ab%20cd&mine=1'); +assert.strictEqual(t.coverageUrl('/api/rx-coverage', '1,2,3,4', 9, 7, '', false), '/api/rx-coverage?bbox=1,2,3,4&z=9&days=7'); +assert.strictEqual(t.coverageUrl('/api/rx-coverage', '1,2,3,4', 9, 7, '', true), '/api/rx-coverage?bbox=1,2,3,4&z=9&days=7&mine=1'); +assert.strictEqual(t.coverageUrl('/api/rx-coverage', '1,2,3,4', 9, 30, 'ab cd', true), '/api/rx-coverage?bbox=1,2,3,4&z=9&days=30&rx=ab%20cd&mine=1'); +assert.strictEqual(t.coverageUrl('/api/rf-noise', '1,2,3,4', 9, 14, 'ab', true), '/api/rf-noise?bbox=1,2,3,4&z=9&days=14&rx=ab&mine=1'); // The toggle: nothing for a logged-out visitor, a pressed/unpressed button otherwise. assert.strictEqual(t.mineBtnHtml(null, false), '', 'no toggle for a logged-out visitor'); @@ -44,13 +46,17 @@ assert.strictEqual(t.authUser({ isEnabled: function () { return false; }, user: assert.strictEqual(t.authUser({ isEnabled: function () { return true; }, user: function () { return null; } }), null); assert.strictEqual(t.authUser({ isEnabled: function () { return true; }, user: function () { return u; } }), u); -// Wiring: the signal layer builds its URL with the toggle, and the bar starts hidden. -assert.ok(code.indexOf('var mineSent = mine && !!authUser(window.CSAuth);') !== -1 && - code.indexOf('coverageUrl(bbox, map.getZoom(), days, selectedRx, mineSent)') !== -1, - 'drawSignalLayer must build its URL with coverageUrl and the toggle'); +// Wiring: every layer fetches through fetchLayer, which builds its URL with +// the observer filter and the toggle; the bar starts hidden. +assert.ok(code.indexOf('function mineSent() { return mine && !!authUser(window.CSAuth); }') !== -1 && + code.indexOf('coverageUrl(path, bbox, map.getZoom(), days, selectedRx, sentMine)') !== -1, + 'fetchLayer must build its URL with coverageUrl, the observer filter and the toggle'); +assert.ok(/fetchLayer\('\/api\/rx-coverage', bbox, /.test(code), 'the signal layer must fetch through fetchLayer'); +assert.ok(/fetchLayer\('\/api\/rf-noise', bbox, /.test(code), 'the noise layer must fetch through fetchLayer'); +assert.strictEqual((code.match(/fetch\('\/api\/rf-noise/g) || []).length, 0, 'no unfiltered noise fetch'); assert.ok(/id="rxMineBar"[^>]*hidden/.test(code), 'the toggle bar must start hidden'); -// A reply only counts when it answers the newest signal-layer request: an +// A reply only counts when it answers the newest layer request: an // older, unfiltered reply must not overwrite "My coverage" (or the reverse). assert.strictEqual(t.coverageReply(1, 2, 200, false), 'ignore'); assert.strictEqual(t.coverageReply(2, 2, 200, true), 'draw'); @@ -62,7 +68,20 @@ assert.strictEqual(t.coverageReply(1, 2, 401, true), 'ignore'); // Any other failure is an error, never parsed as a FeatureCollection. assert.strictEqual(t.coverageReply(2, 2, 401, false), 'error'); assert.strictEqual(t.coverageReply(2, 2, 500, true), 'error'); -assert.ok(/coverageReply\(seq, signalSeq, r\.status, mineSent\)/.test(code), - 'drawSignalLayer must judge each reply with coverageReply'); +assert.ok(/coverageReply\(seq, drawSeq, r\.status, sentMine\)/.test(code), + 'fetchLayer must judge each reply with coverageReply'); -console.log('rx-coverage My coverage OK'); +// The headers can be newest while the body is still on its way: a newer +// request started in between must win when the old body lands (#2326). +(async function () { + let latest = 1; + let finish; + const late = t.freshBody(1, function () { return latest; }, new Promise(function (r) { finish = r; })); + latest = 2; + finish({ features: [1] }); + assert.strictEqual(await late, null, 'a body that lands after a newer request must be dropped'); + assert.deepStrictEqual(await t.freshBody(2, function () { return latest; }, Promise.resolve({ features: [2] })), { features: [2] }); + assert.ok(/freshBody\(seq, function \(\) \{ return drawSeq; \}, r\.json\(\)\)/.test(code), + 'fetchLayer must judge the body again when it lands'); + console.log('rx-coverage My coverage OK'); +})().catch(function (e) { console.error(e); process.exit(1); });