(feat): better connection management, utility functions & tests

This commit is contained in:
Alois 2026-04-14 01:37:18 +02:00
commit 822dfea577
14 changed files with 448 additions and 39 deletions

View file

@ -1,8 +1,8 @@
# TypeScript TAuth SDK # TypeScript TAuth SDK
TypeScript SDK for TAuth-based login and transport session bootstrap. TypeScript SDK for TAuth-based login and explicit transport session management.
This SDK starts a local HTTP callback server, verifies a challenge, and opens an authenticated TTP transport client per `userId:sessionId`. This SDK starts a local HTTP callback server, verifies a challenge, and gives you explicit control over TTP connections per `userId:sessionId`.
## Requirements ## Requirements
@ -43,6 +43,11 @@ const client = new TAuthClient({
identifier: "your-app-identifier", identifier: "your-app-identifier",
privateKey: "<APP_PRIVATE_KEY_BASE64>", privateKey: "<APP_PRIVATE_KEY_BASE64>",
publicKey: "<APP_PUBLIC_KEY_BASE64>", publicKey: "<APP_PUBLIC_KEY_BASE64>",
saveSession: async (userId, sessionId) => {
// You must persist the session id yourself (for example in a database).
// Treat the session id like a password!
console.log("Save session", { userId, sessionId });
},
redirectUrl: "http://localhost:7878/callback", redirectUrl: "http://localhost:7878/callback",
appData: z.object({ appData: z.object({
my: z.string(), my: z.string(),
@ -56,9 +61,15 @@ const client = new TAuthClient({
}); });
``` ```
## Endpoints exposed by the SDK ## Auth endpoints exposed by the SDK
- `GET /auth`: Redirects user to TAuth frontend - `GET /auth`: Redirects user to TAuth frontend
- `GET /callback`: Handles challenge flow and session bootstrap - `GET /callback`: Handles challenge flow and invokes your `saveSession(userId, sessionId)` callback
## Connection management
- Use `client.createTTP(userId, sessionId, omikronUrl)` when you want a persistent/manual TTP connection.
- Use `client.loadData(userId, sessionId)` for loading user specific application data.
- Use `client.saveData(userId, sessionId)` for saving user specific application data.
These endpoints need to be exposed behind some kind of http proxy to apply ssl These endpoints need to be exposed behind some kind of http proxy to apply ssl

55
dist/index.d.ts vendored
View file

@ -1,6 +1,6 @@
import { type TransportClient, READY_STATE } from "@tensamin/ttp-core"; import { type TransportClient, READY_STATE } from "@tensamin/ttp-core";
import { createSchema } from "./schema"; import { createSchema } from "./schema";
import type { ZodObject } from "zod"; import type { z, ZodObject } from "zod";
/** /**
* @param redirectUrl The URL should point to the TAuth HTTP Server at http://hostname:port/callback * @param redirectUrl The URL should point to the TAuth HTTP Server at http://hostname:port/callback
*/ */
@ -9,6 +9,7 @@ export declare class TAuthClient {
identifier: string; identifier: string;
privateKey: string; privateKey: string;
publicKey: string; publicKey: string;
saveSession: (userId: number, sessionId: number) => Promise<void> | void;
redirectUrl: URL; redirectUrl: URL;
schema: ReturnType<typeof createSchema>; schema: ReturnType<typeof createSchema>;
httpServer: { httpServer: {
@ -17,31 +18,43 @@ export declare class TAuthClient {
}; };
clientMap: Map<string, TransportClient<{ clientMap: Map<string, TransportClient<{
identification: { identification: {
request: ZodObject<{ request: z.ZodObject<{
app_identifier: import("zod").ZodString; app_identifier: z.ZodString;
app_session_id: import("zod").ZodNumber; app_session_id: z.ZodNumber;
app_public_key: import("zod").ZodString; app_public_key: z.ZodString;
user_id: import("zod").ZodNumber; user_id: z.ZodNumber;
}, import("zod/v4/core").$strip>; }, z.core.$strip>;
response: ZodObject<{ response: z.ZodObject<{
challenge: import("zod").ZodBase64; challenge: z.ZodBase64;
public_key: import("zod").ZodBase64; public_key: z.ZodBase64;
}, import("zod/v4/core").$strip>; }, z.core.$strip>;
}; };
challenge_response: { challenge_response: {
request: ZodObject<{ request: z.ZodObject<{
challenge: import("zod").ZodBase64; challenge: z.ZodBase64;
}, import("zod/v4/core").$strip>; }, z.core.$strip>;
response: ZodObject<{ response: z.ZodObject<{}, z.core.$strip>;
app_data: ZodObject<import("zod/v4/core").$ZodLooseShape, import("zod/v4/core").$strip>; };
}, import("zod/v4/core").$strip>; load_app_data: {
request: z.ZodObject<{}, z.core.$strip>;
response: z.ZodObject<{
app_data: z.ZodString;
}, z.core.$strip>;
};
save_app_data: {
request: z.ZodObject<{
app_data: z.ZodString;
}, z.core.$strip>;
response: z.ZodObject<{}, z.core.$strip>;
}; };
}>>; }>>;
constructor({ frontendUrl, identifier, privateKey, publicKey, redirectUrl, appData, httpServer, htmlSuccessPage, }: { private appData;
constructor({ frontendUrl, identifier, privateKey, publicKey, saveSession, redirectUrl, appData, httpServer, htmlSuccessPage, }: {
frontendUrl: string; frontendUrl: string;
identifier: string; identifier: string;
privateKey: string; privateKey: string;
publicKey: string; publicKey: string;
saveSession: (userId: number, sessionId: number) => Promise<void> | void;
redirectUrl: string; redirectUrl: string;
appData: ZodObject; appData: ZodObject;
httpServer?: { httpServer?: {
@ -53,6 +66,9 @@ export declare class TAuthClient {
generateChallenge(userId: number): Promise<string>; generateChallenge(userId: number): Promise<string>;
solveChallenge(userId: number, challenge: string): Promise<string>; solveChallenge(userId: number, challenge: string): Promise<string>;
createTTP(userId: number, sessionId: number, omikronUrl: string): void; createTTP(userId: number, sessionId: number, omikronUrl: string): void;
loadData(userId: number, sessionId: number): Promise<Record<string, unknown>>;
saveData(userId: number, sessionId: number, data: any): Promise<void>;
private createTemporaryTTPConnection;
generateLink(challenge?: string): string; generateLink(challenge?: string): string;
} }
export declare function getFriendlyReadyState(stateIndex: number): keyof typeof READY_STATE; export declare function getFriendlyReadyState(stateIndex: number): keyof typeof READY_STATE;
@ -60,3 +76,6 @@ export declare function generateKeyPair(): {
private: string; private: string;
public: string; public: string;
}; };
export * from "./crypto";
export * from "./schema";
export * from "./user";

57
dist/index.js vendored
View file

@ -13,17 +13,24 @@ export class TAuthClient {
identifier; identifier;
privateKey; privateKey;
publicKey; publicKey;
saveSession;
redirectUrl; redirectUrl;
schema; schema;
httpServer; httpServer;
clientMap = new Map(); clientMap = new Map();
constructor({ frontendUrl, identifier, privateKey, publicKey, redirectUrl, appData, httpServer, htmlSuccessPage, }) { appData;
constructor({ frontendUrl, identifier, privateKey, publicKey, saveSession, redirectUrl, appData, httpServer, htmlSuccessPage, }) {
this.frontendUrl = frontendUrl; this.frontendUrl = frontendUrl;
this.identifier = identifier; this.identifier = identifier;
this.privateKey = privateKey; this.privateKey = privateKey;
this.publicKey = publicKey; this.publicKey = publicKey;
if (typeof saveSession !== "function") {
throw new Error("[tauth] saveSession must be provided as a function");
}
this.saveSession = saveSession;
this.redirectUrl = new URL(redirectUrl); this.redirectUrl = new URL(redirectUrl);
this.schema = createSchema(appData); this.schema = createSchema(appData);
this.appData = appData;
this.httpServer = httpServer ?? { this.httpServer = httpServer ?? {
port: 7878, port: 7878,
hostname: "localhost", hostname: "localhost",
@ -62,8 +69,7 @@ export class TAuthClient {
} }
if ((await this.solveChallenge(userId, originalChallenge)) === if ((await this.solveChallenge(userId, originalChallenge)) ===
potentialChallenge) { potentialChallenge) {
const { ip_address } = await fetch("https://omega.tensamin.net/api/get/omikron/" + userId).then((res) => res.json()); await this.saveSession(userId, potentialSessionId);
this.createTTP(userId, potentialSessionId, ip_address);
return new Response(htmlSuccessPage || (await readFile("./index.html")), { return new Response(htmlSuccessPage || (await readFile("./index.html")), {
headers: { headers: {
"Content-Type": "text/html", "Content-Type": "text/html",
@ -133,6 +139,48 @@ export class TAuthClient {
})); }));
this.clientMap.get(`${userId}:${sessionId}`)?.connect(); this.clientMap.get(`${userId}:${sessionId}`)?.connect();
} }
async loadData(userId, sessionId) {
const connection = await this.createTemporaryTTPConnection(userId, sessionId);
const rawAppData = await connection.send("load_app_data", {});
const appData = JSON.parse(rawAppData.data.app_data);
const safeAppData = this.appData.safeParse(appData);
await connection.close();
if (!safeAppData.success) {
throw new Error(`Invalid app data: ${JSON.stringify(safeAppData.error.issues)}`);
}
return safeAppData.data;
}
async saveData(userId, sessionId, data) {
const connection = await this.createTemporaryTTPConnection(userId, sessionId);
const safeData = this.appData.safeParse(data);
if (!safeData.success) {
throw new Error(`Invalid app data: ${JSON.stringify(safeData.error.issues)}`);
}
await connection.send("save_app_data", {
app_data: JSON.stringify(safeData.data),
});
await connection.close();
}
async createTemporaryTTPConnection(userId, sessionId) {
const { ip_address } = await fetch("https://omega.tensamin.net/api/get/omikron/" + userId).then((res) => res.json());
let temporaryClient;
temporaryClient = createTransportClient(this.schema, {
url: ip_address,
onReadyStateChange: (stateIndex) => {
const state = getFriendlyReadyState(stateIndex);
if (state === "OPEN") {
temporaryClient?.send("identification", {
app_identifier: this.identifier,
app_session_id: sessionId,
app_public_key: this.publicKey,
user_id: userId,
});
}
},
});
temporaryClient.connect();
return temporaryClient;
}
generateLink(challenge) { generateLink(challenge) {
return new URL(`?identifier=${this.identifier}&redirect=${this.redirectUrl}${challenge ? `&challenge=${challenge}` : ""}`, this.frontendUrl).toString(); return new URL(`?identifier=${this.identifier}&redirect=${this.redirectUrl}${challenge ? `&challenge=${challenge}` : ""}`, this.frontendUrl).toString();
} }
@ -159,3 +207,6 @@ export function generateKeyPair() {
public: toBase64(pub), public: toBase64(pub),
}; };
} }
export * from "./crypto";
export * from "./schema";
export * from "./user";

12
dist/schema.d.ts vendored
View file

@ -16,8 +16,18 @@ export declare function createSchema(appData: ZodObject): {
request: z.ZodObject<{ request: z.ZodObject<{
challenge: z.ZodBase64; challenge: z.ZodBase64;
}, z.core.$strip>; }, z.core.$strip>;
response: z.ZodObject<{}, z.core.$strip>;
};
load_app_data: {
request: z.ZodObject<{}, z.core.$strip>;
response: z.ZodObject<{ response: z.ZodObject<{
app_data: z.ZodObject<z.core.$ZodLooseShape, z.core.$strip>; app_data: z.ZodString;
}, z.core.$strip>; }, z.core.$strip>;
}; };
save_app_data: {
request: z.ZodObject<{
app_data: z.ZodString;
}, z.core.$strip>;
response: z.ZodObject<{}, z.core.$strip>;
};
}; };

12
dist/schema.js vendored
View file

@ -17,9 +17,19 @@ export function createSchema(appData) {
request: z.object({ request: z.object({
challenge: z.base64(), challenge: z.base64(),
}), }),
response: z.object({}),
},
load_app_data: {
request: z.object({}),
response: z.object({ response: z.object({
app_data: appData, app_data: z.string(),
}), }),
}, },
save_app_data: {
request: z.object({
app_data: z.string(),
}),
response: z.object({}),
},
}; };
} }

1
dist/user.d.ts vendored
View file

@ -10,5 +10,4 @@ export type User = {
status_message: string; status_message: string;
about: string; about: string;
}; };
export declare const userCacheMap: Map<number, User>;
export declare function get(userId: number): Promise<User | undefined>; export declare function get(userId: number): Promise<User | undefined>;

2
dist/user.js vendored
View file

@ -1,4 +1,4 @@
export const userCacheMap = new Map(); const userCacheMap = new Map();
export async function get(userId) { export async function get(userId) {
try { try {
const cachedUser = userCacheMap.get(userId); const cachedUser = userCacheMap.get(userId);

View file

@ -1,6 +1,6 @@
{ {
"name": "@tensamin/tauth-sdk", "name": "@tensamin/tauth-sdk",
"version": "0.0.1", "version": "0.0.2",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@ -15,7 +15,8 @@
} }
}, },
"scripts": { "scripts": {
"build": "tsc -p tsconfig.build.json" "build": "bun run test && tsc -p tsconfig.build.json",
"test": "bun test"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.12", "@types/bun": "^1.3.12",

View file

@ -4,7 +4,7 @@ import {
READY_STATE, READY_STATE,
} from "@tensamin/ttp-core"; } from "@tensamin/ttp-core";
import { createSchema } from "./schema"; import { createSchema } from "./schema";
import type { ZodObject } from "zod"; import type { z, ZodObject } from "zod";
import { x448 } from "@noble/curves/ed448.js"; import { x448 } from "@noble/curves/ed448.js";
import Bun from "bun"; import Bun from "bun";
import { get } from "./user"; import { get } from "./user";
@ -19,6 +19,7 @@ export class TAuthClient {
identifier: string; identifier: string;
privateKey: string; privateKey: string;
publicKey: string; publicKey: string;
saveSession: (userId: number, sessionId: number) => Promise<void> | void;
redirectUrl: URL; redirectUrl: URL;
schema: ReturnType<typeof createSchema>; schema: ReturnType<typeof createSchema>;
httpServer: { httpServer: {
@ -26,12 +27,14 @@ export class TAuthClient {
hostname: string; hostname: string;
}; };
clientMap = new Map<string, TransportClient<typeof this.schema>>(); clientMap = new Map<string, TransportClient<typeof this.schema>>();
private appData: ZodObject;
constructor({ constructor({
frontendUrl, frontendUrl,
identifier, identifier,
privateKey, privateKey,
publicKey, publicKey,
saveSession,
redirectUrl, redirectUrl,
appData, appData,
httpServer, httpServer,
@ -41,6 +44,7 @@ export class TAuthClient {
identifier: string; identifier: string;
privateKey: string; privateKey: string;
publicKey: string; publicKey: string;
saveSession: (userId: number, sessionId: number) => Promise<void> | void;
redirectUrl: string; redirectUrl: string;
appData: ZodObject; appData: ZodObject;
httpServer?: { httpServer?: {
@ -53,8 +57,13 @@ export class TAuthClient {
this.identifier = identifier; this.identifier = identifier;
this.privateKey = privateKey; this.privateKey = privateKey;
this.publicKey = publicKey; this.publicKey = publicKey;
if (typeof saveSession !== "function") {
throw new Error("[tauth] saveSession must be provided as a function");
}
this.saveSession = saveSession;
this.redirectUrl = new URL(redirectUrl); this.redirectUrl = new URL(redirectUrl);
this.schema = createSchema(appData); this.schema = createSchema(appData);
this.appData = appData;
this.httpServer = httpServer ?? { this.httpServer = httpServer ?? {
port: 7878, port: 7878,
hostname: "localhost", hostname: "localhost",
@ -102,11 +111,7 @@ export class TAuthClient {
(await this.solveChallenge(userId, originalChallenge)) === (await this.solveChallenge(userId, originalChallenge)) ===
potentialChallenge potentialChallenge
) { ) {
const { ip_address } = await fetch( await this.saveSession(userId, potentialSessionId);
"https://omega.tensamin.net/api/get/omikron/" + userId,
).then((res) => res.json());
this.createTTP(userId, potentialSessionId, ip_address);
return new Response( return new Response(
htmlSuccessPage || (await readFile("./index.html")), htmlSuccessPage || (await readFile("./index.html")),
@ -210,6 +215,76 @@ export class TAuthClient {
this.clientMap.get(`${userId}:${sessionId}`)?.connect(); this.clientMap.get(`${userId}:${sessionId}`)?.connect();
} }
async loadData(userId: number, sessionId: number) {
const connection = await this.createTemporaryTTPConnection(
userId,
sessionId,
);
const rawAppData = await connection.send("load_app_data", {});
const appData = JSON.parse(rawAppData.data.app_data);
const safeAppData = this.appData.safeParse(appData);
await connection.close();
if (!safeAppData.success) {
throw new Error(
`Invalid app data: ${JSON.stringify(safeAppData.error.issues)}`,
);
}
return safeAppData.data;
}
async saveData(userId: number, sessionId: number, data: any) {
const connection = await this.createTemporaryTTPConnection(
userId,
sessionId,
);
const safeData = this.appData.safeParse(data);
if (!safeData.success) {
throw new Error(
`Invalid app data: ${JSON.stringify(safeData.error.issues)}`,
);
}
await connection.send("save_app_data", {
app_data: JSON.stringify(safeData.data),
});
await connection.close();
}
private async createTemporaryTTPConnection(
userId: number,
sessionId: number,
): Promise<TransportClient<typeof this.schema>> {
const { ip_address } = await fetch(
"https://omega.tensamin.net/api/get/omikron/" + userId,
).then((res) => res.json());
let temporaryClient: TransportClient<typeof this.schema> | undefined;
temporaryClient = createTransportClient(this.schema, {
url: ip_address,
onReadyStateChange: (stateIndex) => {
const state = getFriendlyReadyState(stateIndex);
if (state === "OPEN") {
temporaryClient?.send("identification", {
app_identifier: this.identifier,
app_session_id: sessionId,
app_public_key: this.publicKey,
user_id: userId,
});
}
},
});
temporaryClient.connect();
return temporaryClient;
}
generateLink(challenge?: string): string { generateLink(challenge?: string): string {
return new URL( return new URL(
`?identifier=${this.identifier}&redirect=${this.redirectUrl}${challenge ? `&challenge=${challenge}` : ""}`, `?identifier=${this.identifier}&redirect=${this.redirectUrl}${challenge ? `&challenge=${challenge}` : ""}`,
@ -251,4 +326,4 @@ export function generateKeyPair(): { private: string; public: string } {
export * from "./crypto"; export * from "./crypto";
export * from "./schema"; export * from "./schema";
export * from "./user"; export * from "./user";

View file

@ -18,9 +18,20 @@ export function createSchema(appData: ZodObject) {
request: z.object({ request: z.object({
challenge: z.base64(), challenge: z.base64(),
}), }),
response: z.object({}),
},
load_app_data: {
request: z.object({}),
response: z.object({ response: z.object({
app_data: appData, app_data: z.string(),
}), }),
}, },
save_app_data: {
request: z.object({
app_data: z.string(),
}),
response: z.object({}),
},
}; };
} }

62
test/crypto.test.ts Normal file
View file

@ -0,0 +1,62 @@
import { describe, expect, test } from "bun:test";
import { x448 } from "@noble/curves/ed448.js";
import { decrypt, encrypt, getSharedSecret } from "../src/crypto";
function toBase64(bytes: Uint8Array): string {
if (typeof Buffer !== "undefined") {
return Buffer.from(bytes).toString("base64");
}
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
describe("crypto", () => {
test("encrypt/decrypt roundtrip", async () => {
const privA = new Uint8Array(56);
crypto.getRandomValues(privA);
const pubA = x448.getPublicKey(privA);
const privB = new Uint8Array(56);
crypto.getRandomValues(privB);
const pubB = x448.getPublicKey(privB);
const sharedA = await getSharedSecret(
toBase64(privA),
toBase64(pubA),
toBase64(pubB),
);
const sharedB = await getSharedSecret(
toBase64(privB),
toBase64(pubB),
toBase64(pubA),
);
expect(sharedA).toBe(sharedB);
const message = "hello tauth sdk";
const cipher = await encrypt(sharedA, message);
const plain = await decrypt(sharedB, cipher);
expect(plain).toBe(message);
});
test("decrypt fails when using wrong shared secret", async () => {
const secretA = "01".repeat(56);
const secretB = "02".repeat(56);
const cipher = await encrypt(secretA, "sensitive");
await expect(decrypt(secretB, cipher)).rejects.toThrow();
});
test("getSharedSecret rejects malformed key material", async () => {
await expect(getSharedSecret("AA==", "AA==", "AA==")).rejects.toThrow(
"not a valid X448",
);
});
});

View file

@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test";
import { READY_STATE } from "@tensamin/ttp-core";
import { TAuthClient, generateKeyPair, getFriendlyReadyState } from "../src/index";
describe("index helpers", () => {
test("generateKeyPair returns base64 keys with expected length", () => {
const pair = generateKeyPair();
const priv = Buffer.from(pair.private, "base64");
const pub = Buffer.from(pair.public, "base64");
expect(priv.length).toBe(56);
expect(pub.length).toBe(56);
});
test("getFriendlyReadyState maps numeric state to key", () => {
for (const [name, value] of Object.entries(READY_STATE)) {
expect(getFriendlyReadyState(value)).toBe(name);
}
});
test("generateLink builds auth URL with and without challenge", () => {
const client = Object.create(TAuthClient.prototype) as TAuthClient;
client.frontendUrl = "https://tauth.example.com/login";
client.identifier = "my-app";
client.redirectUrl = new URL("https://app.example.com/callback");
const withoutChallenge = client.generateLink();
const withChallenge = client.generateLink("abcdef");
const parsedWithout = new URL(withoutChallenge);
const parsedWith = new URL(withChallenge);
expect(parsedWithout.searchParams.get("identifier")).toBe("my-app");
expect(parsedWithout.searchParams.get("redirect")).toBe(
"https://app.example.com/callback",
);
expect(parsedWith.searchParams.get("challenge")).toBe("abcdef");
});
});

39
test/schema.test.ts Normal file
View file

@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test";
import z from "zod";
import { createSchema } from "../src/schema";
describe("schema", () => {
const appData = z.object({
my: z.string(),
cool: z.string(),
});
const schema = createSchema(appData);
test("accepts valid identification request", () => {
const parsed = schema.identification.request.parse({
app_identifier: "app-1",
app_session_id: 42,
app_public_key: "base64key",
user_id: 123,
});
expect(parsed.user_id).toBe(123);
});
test("rejects invalid challenge response request", () => {
const result = schema.challenge_response.request.safeParse({
challenge: "not-base64",
});
expect(result.success).toBe(false);
});
test("accepts valid save_app_data request", () => {
const result = schema.save_app_data.request.safeParse({
app_data: JSON.stringify({ my: "a", cool: "b" }),
});
expect(result.success).toBe(true);
});
});

82
test/user.test.ts Normal file
View file

@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { get } from "../src/user";
describe("user.get", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
// Reset mocks each test and isolate IDs to avoid cache collisions.
mock.restore();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
test("fetches and returns user data", async () => {
const fetchMock = mock(async () => {
return {
json: async () => ({
status: "ok",
username: "alice",
public_key: "pub",
user_id: 9001,
iota_id: 1,
sub_level: 0,
sub_end: 0,
display: "Alice",
status_message: "hi",
about: "about",
}),
} as Response;
});
globalThis.fetch = fetchMock as typeof fetch;
const result = await get(9001);
expect(result?.username).toBe("alice");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test("returns cached value for repeated user id", async () => {
const fetchMock = mock(async () => {
return {
json: async () => ({
status: "ok",
username: "bob",
public_key: "pub2",
user_id: 9002,
iota_id: 1,
sub_level: 0,
sub_end: 0,
display: "Bob",
status_message: "hi",
about: "about",
}),
} as Response;
});
globalThis.fetch = fetchMock as typeof fetch;
const first = await get(9002);
const second = await get(9002);
expect(first?.username).toBe("bob");
expect(second?.username).toBe("bob");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test("returns undefined when fetch fails", async () => {
const fetchMock = mock(async () => {
throw new Error("network down");
});
globalThis.fetch = fetchMock as typeof fetch;
const result = await get(9003);
expect(result).toBeUndefined();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});