mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-18 11:10:15 +00:00
Atlas cards now use the Signal tab StatCard tiles, shared Card, badges, action buttons, IATA chips and search bar. Polling moves to TanStack Query with the app retry policy, pauses while rate-limited, and recovers a pin whose server ID changed by its full public key. Search results show a key prefix and explain short or non-hex input; heard-by states which packet it counts. Sparkline component removed in favour of the stats one.
126 lines
3.6 KiB
TypeScript
126 lines
3.6 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
parseRetryAfter,
|
|
queryRetryDelay,
|
|
shouldRetryQuery,
|
|
noteRateLimited,
|
|
noteRequestOk,
|
|
getRateLimitedUntil,
|
|
isRateLimited,
|
|
subscribeRateLimit,
|
|
} from "../../src/api/rate-limit";
|
|
import { RATE_LIMIT_DEFAULT_MS, RATE_LIMIT_MAX_MS } from "../../src/lib/constants";
|
|
|
|
afterEach(() => {
|
|
noteRequestOk();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe("parseRetryAfter", () => {
|
|
it("reads delta-seconds as milliseconds", () => {
|
|
expect(parseRetryAfter("7")).toBe(7_000);
|
|
});
|
|
|
|
it("reads an HTTP-date relative to now", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-09-02T12:00:00Z"));
|
|
expect(parseRetryAfter("Wed, 02 Sep 2026 12:00:30 GMT")).toBe(30_000);
|
|
});
|
|
|
|
it("returns undefined for a missing or junk header", () => {
|
|
expect(parseRetryAfter(null)).toBeUndefined();
|
|
expect(parseRetryAfter("soon")).toBeUndefined();
|
|
});
|
|
|
|
it("clamps absurd values to the ceiling", () => {
|
|
expect(parseRetryAfter("9999")).toBe(RATE_LIMIT_MAX_MS);
|
|
});
|
|
});
|
|
|
|
describe("shouldRetryQuery", () => {
|
|
it("never retries a 429", () => {
|
|
expect(shouldRetryQuery(0, { status: 429 })).toBe(false);
|
|
});
|
|
|
|
it("never retries a 404", () => {
|
|
expect(shouldRetryQuery(0, { status: 404 })).toBe(false);
|
|
});
|
|
|
|
it("retries a 5xx twice then stops", () => {
|
|
expect(shouldRetryQuery(0, { status: 500 })).toBe(true);
|
|
expect(shouldRetryQuery(1, { status: 500 })).toBe(true);
|
|
expect(shouldRetryQuery(2, { status: 500 })).toBe(false);
|
|
});
|
|
|
|
it("retries a network error", () => {
|
|
expect(shouldRetryQuery(0, new TypeError("Failed to fetch"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("queryRetryDelay", () => {
|
|
it("honours Retry-After and otherwise backs off exponentially to a ceiling", () => {
|
|
expect(queryRetryDelay(0, Object.assign(new Error("429"), { retryAfterMs: 7_000 }))).toBe(7_000);
|
|
expect(queryRetryDelay(2, new Error("500"))).toBe(4_000);
|
|
expect(queryRetryDelay(10, new Error("500"))).toBe(30_000);
|
|
});
|
|
});
|
|
|
|
describe("rate-limit store", () => {
|
|
it("starts un-limited", () => {
|
|
expect(getRateLimitedUntil()).toBeNull();
|
|
expect(isRateLimited()).toBe(false);
|
|
});
|
|
|
|
it("noteRateLimited opens the window and notifies subscribers", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(1_000_000);
|
|
const cb = vi.fn();
|
|
const unsub = subscribeRateLimit(cb);
|
|
|
|
noteRateLimited(5_000);
|
|
|
|
expect(getRateLimitedUntil()).toBe(1_005_000);
|
|
expect(isRateLimited()).toBe(true);
|
|
expect(cb).toHaveBeenCalledOnce();
|
|
unsub();
|
|
});
|
|
|
|
it("uses the default window when no Retry-After was given", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(1_000_000);
|
|
noteRateLimited();
|
|
expect(getRateLimitedUntil()).toBe(1_000_000 + RATE_LIMIT_DEFAULT_MS);
|
|
});
|
|
|
|
it("is no longer limited once the window elapses", () => {
|
|
vi.useFakeTimers();
|
|
noteRateLimited(5_000);
|
|
vi.advanceTimersByTime(5_000);
|
|
expect(isRateLimited()).toBe(false);
|
|
});
|
|
|
|
it("a longer window extends, a shorter one does not shrink", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(0);
|
|
noteRateLimited(5_000);
|
|
noteRateLimited(10_000);
|
|
expect(getRateLimitedUntil()).toBe(10_000);
|
|
noteRateLimited(2_000);
|
|
expect(getRateLimitedUntil()).toBe(10_000);
|
|
});
|
|
|
|
it("noteRequestOk clears and notifies, and stays quiet when nothing was set", () => {
|
|
const cb = vi.fn();
|
|
const unsub = subscribeRateLimit(cb);
|
|
|
|
noteRequestOk();
|
|
expect(cb).not.toHaveBeenCalled();
|
|
|
|
noteRateLimited(5_000);
|
|
noteRequestOk();
|
|
expect(getRateLimitedUntil()).toBeNull();
|
|
expect(cb).toHaveBeenCalledTimes(2);
|
|
unsub();
|
|
});
|
|
});
|