Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion spec/unit/matrix-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { type MockedObject, type Mocked } from "vitest";
import { logger } from "../../src/logger";
import {
ClientEvent,
type IClientWellKnown,
type IMatrixClientCreateOpts,
type ITurnServerResponse,
MatrixClient,
Expand All @@ -48,6 +49,7 @@ import * as testUtils from "../test-utils/test-utils";
import { makeBeaconInfoContent } from "../../src/content-helpers";
import { M_BEACON_INFO } from "../../src/@types/beacon";
import {
AutoDiscovery,
ClientPrefix,
ConditionKind,
ContentHelpers,
Expand Down Expand Up @@ -199,6 +201,12 @@ describe("MatrixClient", function () {
data: {},
};

const RTC_TRANSPORT_RESPONSE: HttpLookup = {
method: "GET",
path: "/rtc/transports/",
data: { rtc_transports: [] },
};

const FILTER_PATH = "/user/" + encodeURIComponent(userId) + "/filter";

const FILTER_RESPONSE: HttpLookup = {
Expand Down Expand Up @@ -397,6 +405,7 @@ describe("MatrixClient", function () {
pendingLookup = null;
httpLookups = [];
httpLookups.push(PUSH_RULES_RESPONSE);
httpLookups.push(RTC_TRANSPORT_RESPONSE);
httpLookups.push(FILTER_RESPONSE);
httpLookups.push(SYNC_RESPONSE);
});
Expand Down Expand Up @@ -1603,7 +1612,7 @@ describe("MatrixClient", function () {
});

it("should not POST /filter if a matching filter already exists", async function () {
httpLookups = [PUSH_RULES_RESPONSE, SYNC_RESPONSE];
httpLookups = [PUSH_RULES_RESPONSE, RTC_TRANSPORT_RESPONSE, SYNC_RESPONSE];
const filterId = "ehfewf";
vi.mocked(store.getFilterIdByName).mockReturnValue(filterId);
const filter = new Filter("0", filterId);
Expand Down Expand Up @@ -1718,6 +1727,7 @@ describe("MatrixClient", function () {
});

it("should work on /sync", async () => {
httpLookups.push(RTC_TRANSPORT_RESPONSE);
httpLookups.push({
method: "GET",
path: "/sync",
Expand Down Expand Up @@ -1754,6 +1764,7 @@ describe("MatrixClient", function () {
path: "/pushrules/",
error: { errcode: "NOPE_NOPE_NOPE" },
});
httpLookups.push(RTC_TRANSPORT_RESPONSE);
httpLookups.push(PUSH_RULES_RESPONSE);
httpLookups.push(FILTER_RESPONSE);
httpLookups.push(SYNC_RESPONSE);
Expand Down Expand Up @@ -1814,6 +1825,7 @@ describe("MatrixClient", function () {
const expectedStates: [string, string | null][] = [];
httpLookups = [];
httpLookups.push(PUSH_RULES_RESPONSE);
httpLookups.push(RTC_TRANSPORT_RESPONSE);
httpLookups.push({
method: "POST",
path: FILTER_PATH,
Expand Down Expand Up @@ -3890,6 +3902,30 @@ describe("MatrixClient", function () {
]);
});
});

describe("Well-known", () => {
it("caches the well-known value", async () => {
const A_WELLKNOWN: IClientWellKnown = {
"m.homeserver": {
base_url: "https://hs.org",
},
"m.identity_server": {
base_url: "https://is.org",
},
};

void client.startClient();

vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue(A_WELLKNOWN);

const value = await client.waitForClientWellKnown();
expect(value).toStrictEqual(A_WELLKNOWN);

const cached = client.getClientWellKnown();
expect(cached).toStrictEqual(A_WELLKNOWN);
});
});

describe("getUrlPreview", () => {
it("makes a well-formed request to the new endpoint", async () => {
client.getVersions = vi.fn().mockResolvedValue({
Expand Down
248 changes: 248 additions & 0 deletions spec/unit/pollingCachedValue.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
/*
Copyright 2026 The Matrix.org Foundation C.I.C.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import { afterEach, beforeEach, describe, vi, it, expect, type Mock } from "vitest";
import { PollingCachedValue } from "../../src/pollingCachedValue.ts";
import { MatrixError } from "../../src";
import { logger } from "../../src/logger.ts";

describe("PollingCachedValue", () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

const TTL = 10000;
let fetchFn: Mock<() => Promise<string>>;
let cachedCallback: Mock<(value: string) => void>;
let clientCachedValue: PollingCachedValue<string>;

beforeEach(() => {
fetchFn = vi.fn<() => Promise<string>>();
cachedCallback = vi.fn<(value: string) => void>();
clientCachedValue = new PollingCachedValue({
name: "mock",
logger,
ttlMillis: TTL,
fetch: fetchFn,
onValueCached: cachedCallback,
});
});

it("should fetch and cache the value", async () => {
fetchFn.mockResolvedValue("HelloWorld!");

const value = await clientCachedValue.wait();

expect(value).toBe("HelloWorld!");
expect(clientCachedValue.get()).toBe("HelloWorld!");
expect(cachedCallback).toHaveBeenCalledWith("HelloWorld!");
});

it("should not call again once the value is cached", async () => {
fetchFn.mockResolvedValue("HelloWorld!");

const value = await clientCachedValue.wait();

expect(value).toBe("HelloWorld!");

// ask the value again
await clientCachedValue.wait();

expect(clientCachedValue.get()).toBe("HelloWorld!");
expect(fetchFn).toHaveBeenCalledTimes(1);
});

it("should expire the cache after the specified time", async () => {
fetchFn.mockResolvedValue("HelloWorld!");

const value = await clientCachedValue.wait();
expect(value).toBe("HelloWorld!");

fetchFn.mockResolvedValue("NewValue!");

// Within the refresh period, so still the old value
await vi.advanceTimersByTimeAsync(TTL / 2);
expect(await clientCachedValue.wait()).toBe("HelloWorld!");

// advance time by 1001ms to trigger cache expiration
await vi.advanceTimersByTimeAsync(TTL / 2 + 10);

const newValue = await clientCachedValue.wait();
expect(newValue).toBe("NewValue!");

expect(fetchFn).toHaveBeenCalledTimes(2);
});

it("should automatically retry limit exceeded transient errors", async () => {
fetchFn.mockImplementation(() => {
throw new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED", error: "Too many requests", retry_after_ms: 1000 },
429,
);
});

const fetchPromise = clientCachedValue.wait();
let settled = false;
void fetchPromise.finally(() => {
settled = true;
});

await vi.advanceTimersByTimeAsync(100);

// should still be pending
await Promise.resolve(); // flush microtasks
expect(settled).toBe(false); // still pending

fetchFn.mockResolvedValue("OOO");
await vi.advanceTimersByTimeAsync(2000);

const value = await fetchPromise;
expect(value).toBe("OOO");
expect(settled).toBe(true);

expect(fetchFn).toHaveBeenCalledTimes(2);
});

it("should start fetching and avoid scheduling refresh once stopped", async () => {
const resolver = Promise.withResolvers<string>();
fetchFn.mockImplementation(() => resolver.promise);

clientCachedValue.start();
expect(fetchFn).toHaveBeenCalledTimes(1);

clientCachedValue.stop();
resolver.resolve("FromStart");
await clientCachedValue.wait();

expect(clientCachedValue.get()).toBe("FromStart");

await vi.advanceTimersByTimeAsync(TTL + 10);
expect(fetchFn).toHaveBeenCalledTimes(1);
});

it("should clear an existing timeout handle when stopped", async () => {
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
setTimeoutSpy.mockReturnValue(123 as unknown as ReturnType<typeof setTimeout>);

clientCachedValue.start();
await vi.advanceTimersByTimeAsync(100);
clientCachedValue.stop();

expect(clearTimeoutSpy).toHaveBeenCalledWith(123);
clearTimeoutSpy.mockRestore();
setTimeoutSpy.mockRestore();
});

it("should not arm a refresh trigger if no ttl is provided", async () => {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
setTimeoutSpy.mockReturnValue(123 as unknown as ReturnType<typeof setTimeout>);

clientCachedValue = new PollingCachedValue({ name: "mock", logger, fetch: fetchFn });
clientCachedValue.start();
await vi.advanceTimersByTimeAsync(100);

expect(setTimeoutSpy).not.toHaveBeenCalled();
setTimeoutSpy.mockRestore();
});

it("should let start() override the configured ttl", async () => {
fetchFn.mockResolvedValue("HelloWorld!");

// Configured with TTL, but started with a shorter one
clientCachedValue.start(TTL / 10);
await vi.advanceTimersByTimeAsync(TTL / 10 + 10);

expect(fetchFn).toHaveBeenCalledTimes(2);
});

it("should keep the configured ttl when start() is given none", async () => {
fetchFn.mockResolvedValue("HelloWorld!");

clientCachedValue.start();

await vi.advanceTimersByTimeAsync(TTL / 2);
expect(fetchFn).toHaveBeenCalledTimes(1);

await vi.advanceTimersByTimeAsync(TTL / 2 + 10);
expect(fetchFn).toHaveBeenCalledTimes(2);
});

it("should retry fetch on next wait for non-cacheable errors", async () => {
const nonRetryableError = new MatrixError({ errcode: "M_FORBIDDEN", error: "Forbidden" }, 403);
fetchFn.mockRejectedValueOnce(nonRetryableError).mockResolvedValueOnce("Recovered");

const firstValue = clientCachedValue.wait();
await expect(firstValue).rejects.toThrow(nonRetryableError);
expect(fetchFn).toHaveBeenCalledTimes(1);
expect(clientCachedValue.get()).toBeUndefined();

const secondValue = await clientCachedValue.wait();
expect(secondValue).toBe("Recovered");
expect(fetchFn).toHaveBeenCalledTimes(2);
});

it("should stop retrying immediately when stopped during fetch error", async () => {
const retryableError = new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED", error: "Too many requests", retry_after_ms: 1000 },
429,
);
fetchFn.mockRejectedValue(retryableError);
clientCachedValue.stop();

const value = await clientCachedValue.wait();
expect(value).toBeUndefined();
expect(fetchFn).toHaveBeenCalledTimes(1);
});

it("should reuse same promise for rapid calls", async () => {
const loader = Promise.withResolvers<string>();
fetchFn.mockImplementation(() => loader.promise);

const race = Promise.race([clientCachedValue.wait(), clientCachedValue.wait(), clientCachedValue.wait()]);

await vi.runOnlyPendingTimersAsync();

loader.resolve("FOO");
const value = await race;
expect(value).toBe("FOO");
expect(fetchFn).toHaveBeenCalledTimes(1);
});

it("should reuse rejected promise when error is marked cacheable", async () => {
const cacheableError = new MatrixError({ errcode: "M_FORBIDDEN", error: "Forbidden" }, 403);
const cacheableClientCachedValue = new PollingCachedValue({
name: "mock",
logger,
ttlMillis: TTL,
fetch: fetchFn,
shouldCacheError: () => true,
});
fetchFn.mockRejectedValue(cacheableError);

const firstValue = cacheableClientCachedValue.wait();
await expect(firstValue).rejects.toThrow(cacheableError);
expect(fetchFn).toHaveBeenCalledTimes(1);

const secondValue = cacheableClientCachedValue.wait();
await expect(secondValue).rejects.toThrow(cacheableError);
expect(fetchFn).toHaveBeenCalledTimes(1);
});
});
Loading
Loading