import { describe, it, beforeEach, afterEach, mock } from "node:test"; import assert from "node:assert/strict"; import { MockFetch, mockWindowLocation, restoreWindow, } from "../testHelpers.js"; import { Api, ApiError } from "/js/api.js"; import { TokenRefreshError } from "/js/oauth.js"; function createMockSession(mockResponse = {}) { let lastFetchOptions = null; return { serviceEndpoint: "https://test.example.com", did: "did:plc:testuser", fetch: async (url, options) => { lastFetchOptions = { url, options }; return { ok: true, json: async () => mockResponse, }; }, getLastFetchOptions: () => lastFetchOptions, }; } describe("request", () => { it("should construct URL with path", async () => { const session = createMockSession({}); const api = new Api(session); await api.request("com.example.method"); const { url } = session.getLastFetchOptions(); assert.deepEqual(url, "https://test.example.com/xrpc/com.example.method"); }); it("should append query string to URL", async () => { const session = createMockSession({}); const api = new Api(session); await api.request("com.example.method", { query: { foo: "bar", baz: "qux" }, }); const { url } = session.getLastFetchOptions(); assert.deepEqual( url, "https://test.example.com/xrpc/com.example.method?foo=bar&baz=qux", ); }); it("should default to GET method", async () => { const session = createMockSession({}); const api = new Api(session); await api.request("com.example.method"); const { options } = session.getLastFetchOptions(); assert.deepEqual(options.method, "GET"); }); it("should use specified method", async () => { const session = createMockSession({}); const api = new Api(session); await api.request("com.example.method", { method: "POST" }); const { options } = session.getLastFetchOptions(); assert.deepEqual(options.method, "POST"); }); it("should include Content-Type header by default", async () => { const session = createMockSession({}); const api = new Api(session); await api.request("com.example.method"); const { options } = session.getLastFetchOptions(); assert.deepEqual(options.headers["Content-Type"], "application/json"); }); it("should merge custom headers", async () => { const session = createMockSession({}); const api = new Api(session); await api.request("com.example.method", { headers: { "X-Custom-Header": "custom-value" }, }); const { options } = session.getLastFetchOptions(); assert.deepEqual(options.headers["Content-Type"], "application/json"); assert.deepEqual(options.headers["X-Custom-Header"], "custom-value"); }); it("should stringify body by default", async () => { const session = createMockSession({}); const api = new Api(session); await api.request("com.example.method", { method: "POST", body: { key: "value" }, }); const { options } = session.getLastFetchOptions(); assert.deepEqual(options.body, '{"key":"value"}'); }); it("should not stringify body when stringifyBody is false", async () => { const session = createMockSession({}); const api = new Api(session); const rawBody = new Uint8Array([1, 2, 3]); await api.request("com.example.method", { method: "POST", body: rawBody, stringifyBody: false, }); const { options } = session.getLastFetchOptions(); assert.deepEqual(options.body, rawBody); }); it("should parse JSON response by default", async () => { const session = createMockSession({ result: "success" }); const api = new Api(session); const res = await api.request("com.example.method"); assert.deepEqual(res.data, { result: "success" }); }); it("should not parse JSON when parseJson is false", async () => { const session = createMockSession({ result: "success" }); const api = new Api(session); const res = await api.request("com.example.method", { parseJson: false }); assert.deepEqual(res.data, null); }); it("should throw ApiError when response is not ok", async () => { const session = { serviceEndpoint: "https://test.example.com", did: "did:plc:testuser", fetch: async () => ({ ok: false, status: 400, statusText: "Bad Request", json: async () => ({ error: "InvalidRequest" }), }), }; const api = new Api(session); let thrownError = null; try { await api.request("com.example.method"); } catch (e) { thrownError = e; } assert(thrownError instanceof ApiError); assert.deepEqual(thrownError.status, 400); }); it("should expose error data and status text on ApiError for non-200", async () => { const session = { serviceEndpoint: "https://test.example.com", did: "did:plc:testuser", fetch: async () => ({ ok: false, status: 500, statusText: "Internal Server Error", json: async () => ({ error: "InternalServerError", message: "boom" }), }), }; const api = new Api(session); let thrownError = null; try { await api.request("com.example.method"); } catch (e) { thrownError = e; } assert(thrownError instanceof ApiError); assert.deepEqual(thrownError.status, 500); assert.deepEqual(thrownError.statusText, "Internal Server Error"); assert.deepEqual(thrownError.data.error, "InternalServerError"); }); it("should re-throw non-refresh errors raised during fetch", async () => { const session = { serviceEndpoint: "https://test.example.com", did: "did:plc:testuser", fetch: async () => { throw new Error("network down"); }, }; const api = new Api(session); let thrownError = null; try { await api.request("com.example.method"); } catch (e) { thrownError = e; } assert(thrownError !== null); assert.deepEqual(thrownError.message, "network down"); }); it("should inject atproto-proxy header on AppView-routed requests", async () => { const session = createMockSession({ did: "did:plc:test", handle: "test.user", }); const api = new Api(session); await api.getProfile("did:plc:test"); const { options } = session.getLastFetchOptions(); assert.deepEqual( options.headers["atproto-proxy"], "did:web:api.bsky.app#bsky_appview", ); }); it("should use response data set by oauth library without re-reading", async () => { const cachedData = { result: "from-oauth-lib" }; const session = { serviceEndpoint: "https://test.example.com", did: "did:plc:testuser", fetch: async () => ({ ok: true, data: cachedData, json: async () => { throw new Error("should not be called"); }, }), }; const api = new Api(session); const res = await api.request("com.example.method"); assert.deepEqual(res.data, cachedData); }); }); describe("labelers header stamping", () => { const BSKY_LABELER_DID = "did:plc:ar7c4by46qjdydhdevvrndac"; function createApiWithLabelers(labelers) { const session = createMockSession({}); const api = new Api(session, { getLabelerDids: async () => labelers, }); return { session, api }; } it("stamps the header on appview-proxied requests", async () => { const { session, api } = createApiWithLabelers([ "did:plc:labeler1", BSKY_LABELER_DID, ]); await api.getProfile("did:plc:someone"); const { options } = session.getLastFetchOptions(); assert.deepEqual( options.headers["atproto-accept-labelers"], `did:plc:labeler1, ${BSKY_LABELER_DID};redact`, ); }); it("does not stamp the header on chat requests", async () => { const { session, api } = createApiWithLabelers(["did:plc:labeler1"]); await api.getConvo("convo1"); const { options } = session.getLastFetchOptions(); assert.deepEqual(options.headers["atproto-accept-labelers"], undefined); assert.deepEqual( options.headers["atproto-proxy"], api.chatAppViewServiceDid, ); }); it("stamps the header on getPostThreadOther requests", async () => { const { session, api } = createApiWithLabelers(["did:plc:labeler1"]); await api.getPostThreadOther("at://did:plc:author/app.bsky.feed.post/abc"); const { options } = session.getLastFetchOptions(); assert.deepEqual( options.headers["atproto-accept-labelers"], "did:plc:labeler1", ); }); it("does not stamp the header on unproxied requests", async () => { const { session, api } = createApiWithLabelers(["did:plc:labeler1"]); await api.getPreferences(); const { options } = session.getLastFetchOptions(); assert.deepEqual(options.headers["atproto-accept-labelers"], undefined); }); it("does not stamp the header on requests proxied to other services", async () => { const { session, api } = createApiWithLabelers(["did:plc:labeler1"]); await api.registerPush({ serviceDid: "did:web:push.example.com", token: "token", platform: "web", appId: "app", }); const { options } = session.getLastFetchOptions(); assert.deepEqual(options.headers["atproto-accept-labelers"], undefined); }); it("merges caller-supplied entries after the provider's labelers", async () => { const { session, api } = createApiWithLabelers([ "did:plc:labeler1", BSKY_LABELER_DID, ]); await api.appViewRequest("app.bsky.actor.getProfile", { query: { actor: "did:plc:someone" }, headers: { "atproto-accept-labelers": "did:plc:custom", }, }); const { options } = session.getLastFetchOptions(); assert.deepEqual( options.headers["atproto-accept-labelers"], `did:plc:labeler1, ${BSKY_LABELER_DID};redact, did:plc:custom`, ); }); it("dedupes caller entries already covered by the provider, keeping the provider's suffix", async () => { const { session, api } = createApiWithLabelers([ "did:plc:labeler1", BSKY_LABELER_DID, ]); await api.appViewRequest("app.bsky.actor.getProfile", { query: { actor: "did:plc:someone" }, headers: { "atproto-accept-labelers": `${BSKY_LABELER_DID}, did:plc:custom`, }, }); const { options } = session.getLastFetchOptions(); assert.deepEqual( options.headers["atproto-accept-labelers"], `did:plc:labeler1, ${BSKY_LABELER_DID};redact, did:plc:custom`, ); }); it("keeps caller entries when the provider has no labelers", async () => { const { session, api } = createApiWithLabelers([]); await api.appViewRequest("app.bsky.actor.getProfile", { query: { actor: "did:plc:someone" }, headers: { "atproto-accept-labelers": "did:plc:custom", }, }); const { options } = session.getLastFetchOptions(); assert.deepEqual( options.headers["atproto-accept-labelers"], "did:plc:custom", ); }); it("does not stamp the header on an instance without a labelers provider", async () => { const session = createMockSession({}); const api = new Api(session); await api.getProfile("did:plc:someone"); const { options } = session.getLastFetchOptions(); assert.deepEqual(options.headers["atproto-accept-labelers"], undefined); }); it("does not stamp an empty header when there are no labelers", async () => { const { session, api } = createApiWithLabelers([]); await api.getProfile("did:plc:someone"); const { options } = session.getLastFetchOptions(); assert.deepEqual(options.headers["atproto-accept-labelers"], undefined); }); it("propagates provider failures", async () => { const session = createMockSession({}); const providerError = new Error("preferences failed"); const api = new Api(session, { getLabelerDids: async () => { throw providerError; }, }); await assert.rejects( () => api.getProfile("did:plc:someone"), providerError, ); }); }); describe("token refresh failure", () => { let onTokenRefreshError = null; beforeEach(() => { onTokenRefreshError = mock.fn(() => Promise.resolve()); }); afterEach(() => { restoreWindow(); }); it("soft-logs-out and redirects to login", async () => { const capturedHrefs = mockWindowLocation(""); const session = { serviceEndpoint: "https://test.example.com", did: "did:plc:testuser", handle: "alice.bsky.social", fetch: async () => { throw new TokenRefreshError("refresh failed"); }, }; const api = new Api(session, { onTokenRefreshError }); // The request never settles after the redirect, so don't await it api.request("com.example.method").catch(() => {}); for (let i = 0; i < 50 && capturedHrefs.length === 0; i++) { await Promise.resolve(); } assert.deepEqual(onTokenRefreshError.mock.callCount(), 1); assert.deepEqual( onTokenRefreshError.mock.calls[0].arguments[0], "did:plc:testuser", ); assert.deepEqual(capturedHrefs.at(-1), "/login"); }); }); describe("service DID in requests", () => { it("should use custom bskyAppViewServiceDid in atproto-proxy header", async () => { const session = createMockSession({ did: "did:plc:test", handle: "test.user", }); const customDid = "did:web:custom.bsky.app#custom_appview"; const api = new Api(session, { bskyAppViewServiceDid: customDid }); await api.getProfile("did:plc:test"); const { options } = session.getLastFetchOptions(); assert.deepEqual(options.headers["atproto-proxy"], customDid); }); it("should use custom chatAppViewServiceDid in atproto-proxy header", async () => { const session = createMockSession({ convos: [] }); const customDid = "did:web:custom.chat#custom_chat"; const api = new Api(session, { chatAppViewServiceDid: customDid }); await api.listConvos(); const { options } = session.getLastFetchOptions(); assert.deepEqual(options.headers["atproto-proxy"], customDid); }); }); describe("createLikeRecord", () => { it("should create a like record with correct body", async () => { const session = createMockSession({ uri: "at://did:plc:testuser/app.bsky.feed.like/abc123", cid: "cid123", }); const api = new Api(session); const post = { uri: "at://did:plc:author/app.bsky.feed.post/xyz", cid: "postcid", }; await api.createLikeRecord(post); const { url, options } = session.getLastFetchOptions(); assert(url.includes("com.atproto.repo.createRecord")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.repo, "did:plc:testuser"); assert.deepEqual(body.collection, "app.bsky.feed.like"); assert.deepEqual(body.record.subject.uri, post.uri); assert.deepEqual(body.record.subject.cid, post.cid); }); }); describe("deleteLikeRecord", () => { it("should delete a like record with correct rkey", async () => { const session = createMockSession({}); const api = new Api(session); const post = { viewer: { like: "at://did:plc:testuser/app.bsky.feed.like/likerkey123" }, }; await api.deleteLikeRecord(post); const { url, options } = session.getLastFetchOptions(); assert(url.includes("com.atproto.repo.deleteRecord")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.repo, "did:plc:testuser"); assert.deepEqual(body.collection, "app.bsky.feed.like"); assert.deepEqual(body.rkey, "likerkey123"); }); }); describe("createRepostRecord", () => { it("should create a repost record with correct body", async () => { const session = createMockSession({ uri: "at://did:plc:testuser/app.bsky.feed.repost/abc123", cid: "cid123", }); const api = new Api(session); const post = { uri: "at://did:plc:author/app.bsky.feed.post/xyz", cid: "postcid", }; await api.createRepostRecord(post); const { url, options } = session.getLastFetchOptions(); assert(url.includes("com.atproto.repo.createRecord")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.repo, "did:plc:testuser"); assert.deepEqual(body.collection, "app.bsky.feed.repost"); assert.deepEqual(body.record.subject.uri, post.uri); assert.deepEqual(body.record.subject.cid, post.cid); }); }); describe("deleteRepostRecord", () => { it("should delete a repost record with correct rkey", async () => { const session = createMockSession({}); const api = new Api(session); const post = { viewer: { repost: "at://did:plc:testuser/app.bsky.feed.repost/repostrkey123", }, }; await api.deleteRepostRecord(post); const { url, options } = session.getLastFetchOptions(); assert(url.includes("com.atproto.repo.deleteRecord")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.repo, "did:plc:testuser"); assert.deepEqual(body.collection, "app.bsky.feed.repost"); assert.deepEqual(body.rkey, "repostrkey123"); }); }); describe("createBookmark", () => { it("should create a bookmark with correct body", async () => { const session = createMockSession({}); const api = new Api(session); const post = { uri: "at://did:plc:author/app.bsky.feed.post/xyz", cid: "postcid", }; await api.createBookmark(post); const { url, options } = session.getLastFetchOptions(); assert(url.includes("app.bsky.bookmark.createBookmark")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.uri, post.uri); assert.deepEqual(body.cid, post.cid); }); }); describe("deleteBookmark", () => { it("should delete a bookmark with correct body", async () => { const session = createMockSession({}); const api = new Api(session); const post = { uri: "at://did:plc:author/app.bsky.feed.post/xyz" }; await api.deleteBookmark(post); const { url, options } = session.getLastFetchOptions(); assert(url.includes("app.bsky.bookmark.deleteBookmark")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.uri, post.uri); }); }); describe("createFollowRecord", () => { it("should create a follow record with correct body", async () => { const session = createMockSession({ uri: "at://did:plc:testuser/app.bsky.graph.follow/abc123", cid: "cid123", }); const api = new Api(session); const profile = { did: "did:plc:targetuser" }; await api.createFollowRecord(profile); const { url, options } = session.getLastFetchOptions(); assert(url.includes("com.atproto.repo.createRecord")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.repo, "did:plc:testuser"); assert.deepEqual(body.collection, "app.bsky.graph.follow"); assert.deepEqual(body.record.subject, "did:plc:targetuser"); }); }); describe("deleteFollowRecord", () => { it("should delete a follow record with correct rkey", async () => { const session = createMockSession({}); const api = new Api(session); const profile = { viewer: { following: "at://did:plc:testuser/app.bsky.graph.follow/followrkey123", }, }; await api.deleteFollowRecord(profile); const { url, options } = session.getLastFetchOptions(); assert(url.includes("com.atproto.repo.deleteRecord")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.repo, "did:plc:testuser"); assert.deepEqual(body.collection, "app.bsky.graph.follow"); assert.deepEqual(body.rkey, "followrkey123"); }); }); describe("getPostThread", () => { it("should fetch post thread with correct query params", async () => { const session = createMockSession({ thread: { post: {} } }); const api = new Api(session); await api.getPostThread("at://did:plc:author/app.bsky.feed.post/abc"); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.feed.getPostThread")); assert( url.includes( "uri=at%3A%2F%2Fdid%3Aplc%3Aauthor%2Fapp.bsky.feed.post%2Fabc", ), ); }); }); describe("getPostThreadOther", () => { it("should fetch post thread other with correct query params", async () => { const session = createMockSession({ thread: [] }); const api = new Api(session); await api.getPostThreadOther("at://did:plc:author/app.bsky.feed.post/abc"); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.unspecced.getPostThreadOtherV2")); assert( url.includes( "anchor=at%3A%2F%2Fdid%3Aplc%3Aauthor%2Fapp.bsky.feed.post%2Fabc", ), ); }); }); describe("getFeed", () => { it("should fetch feed with correct query params", async () => { const session = createMockSession({ feed: [], cursor: "nextcursor" }); const api = new Api(session); const result = await api.getFeed( "at://did:plc:feed/app.bsky.feed.generator/myfeed", ); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.feed.getFeed")); assert( url.includes( "feed=at%3A%2F%2Fdid%3Aplc%3Afeed%2Fapp.bsky.feed.generator%2Fmyfeed", ), ); assert.deepEqual(result.cursor, "nextcursor"); }); it("should use custom limit and cursor", async () => { const session = createMockSession({ feed: [] }); const api = new Api(session); await api.getFeed("at://did:plc:feed/app.bsky.feed.generator/myfeed", { limit: 50, cursor: "somecursor", }); const { url } = session.getLastFetchOptions(); assert(url.includes("limit=50")); assert(url.includes("cursor=somecursor")); }); }); describe("getFeedGenerator", () => { it("should fetch feed generator and return view", async () => { const session = createMockSession({ view: { uri: "feeduri", displayName: "My Feed" }, }); const api = new Api(session); const result = await api.getFeedGenerator( "at://did:plc:feed/app.bsky.feed.generator/myfeed", ); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.feed.getFeedGenerator")); assert.deepEqual(result.displayName, "My Feed"); }); }); describe("getFeedGenerators", () => { it("should fetch multiple feed generators", async () => { const session = createMockSession({ feeds: [{ uri: "feed1" }, { uri: "feed2" }], }); const api = new Api(session); const result = await api.getFeedGenerators(["feed1", "feed2"]); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.feed.getFeedGenerators")); assert.deepEqual(result.length, 2); }); }); describe("getFollowingFeed", () => { it("should fetch timeline", async () => { const session = createMockSession({ feed: [], cursor: "nextcursor" }); const api = new Api(session); const result = await api.getFollowingFeed(); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.feed.getTimeline")); assert.deepEqual(result.cursor, "nextcursor"); }); }); describe("getPosts", () => { it("should fetch posts by URIs", async () => { const session = createMockSession({ posts: [{ uri: "post1" }, { uri: "post2" }], }); const api = new Api(session); const result = await api.getPosts([ "at://did:plc:a/app.bsky.feed.post/1", "at://did:plc:b/app.bsky.feed.post/2", ]); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.feed.getPosts")); assert.deepEqual(result.length, 2); }); it("returns posts in request order regardless of response order", async () => { const uriA = "at://did:plc:a/app.bsky.feed.post/1"; const uriB = "at://did:plc:b/app.bsky.feed.post/2"; const uriC = "at://did:plc:c/app.bsky.feed.post/3"; const session = createMockSession({ posts: [{ uri: uriC }, { uri: uriA }, { uri: uriB }], }); const api = new Api(session); const result = await api.getPosts([uriA, uriB, uriC]); assert.deepEqual( result.map((post) => post.uri), [uriA, uriB, uriC], ); }); }); describe("getPost", () => { it("should fetch single post", async () => { const session = createMockSession({ posts: [{ uri: "at://did:plc:a/app.bsky.feed.post/1", text: "hello" }], }); const api = new Api(session); const result = await api.getPost("at://did:plc:a/app.bsky.feed.post/1"); assert.deepEqual(result.text, "hello"); }); it("should throw error when post not found", async () => { const session = createMockSession({ posts: [] }); const api = new Api(session); let thrownError = null; try { await api.getPost("at://did:plc:a/app.bsky.feed.post/notfound"); } catch (e) { thrownError = e; } assert(thrownError !== null); assert(thrownError.message.includes("Post not found")); }); }); describe("getRepost", () => { it("should fetch repost record", async () => { const session = createMockSession({ uri: "at://did:plc:a/app.bsky.feed.repost/1", value: { subject: {} }, }); const api = new Api(session); const result = await api.getRepost("at://did:plc:a/app.bsky.feed.repost/1"); const { url } = session.getLastFetchOptions(); assert(url.includes("com.atproto.repo.getRecord")); assert.deepEqual(result.uri, "at://did:plc:a/app.bsky.feed.repost/1"); }); }); describe("getProfile", () => { it("should fetch profile by DID", async () => { const session = createMockSession({ did: "did:plc:test", handle: "test.bsky.social", }); const api = new Api(session); const result = await api.getProfile("did:plc:test"); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.actor.getProfile")); assert(url.includes("actor=did%3Aplc%3Atest")); assert.deepEqual(result.handle, "test.bsky.social"); }); }); describe("searchProfiles", () => { it("should search profiles with query", async () => { const session = createMockSession({ actors: [{ did: "did:plc:a" }, { did: "did:plc:b" }], }); const api = new Api(session); const result = await api.searchProfiles("test"); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.actor.searchActors")); assert(url.includes("q=test")); assert.deepEqual(result.actors.length, 2); }); }); describe("searchPosts", () => { it("should search posts with query", async () => { const session = createMockSession({ posts: [{ uri: "post1" }], cursor: "next", }); const api = new Api(session); const result = await api.searchPosts("hello"); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.feed.searchPosts")); assert(url.includes("q=hello")); assert.deepEqual(result.cursor, "next"); }); it("should include sort parameter", async () => { const session = createMockSession({ posts: [] }); const api = new Api(session); await api.searchPosts("hello", { sort: "latest" }); const { url } = session.getLastFetchOptions(); assert(url.includes("sort=latest")); }); }); describe("sendInteractions", () => { it("should send interactions to feed proxy", async () => { const session = createMockSession({}); const api = new Api(session); const interactions = [{ uri: "post1", event: "view" }]; await api.sendInteractions( interactions, "did:web:feed.example.com#feed_proxy", ); const { url, options } = session.getLastFetchOptions(); assert(url.includes("app.bsky.feed.sendInteractions")); assert.deepEqual(options.method, "POST"); assert.deepEqual( options.headers["atproto-proxy"], "did:web:feed.example.com#feed_proxy", ); }); it("should reject when no feed proxy url is given", async () => { const session = createMockSession({}); const api = new Api(session); await assert.rejects( api.sendInteractions([{ uri: "post1", event: "view" }], null), /requires a feedProxyUrl/, ); assert.deepEqual(session.getLastFetchOptions(), null); }); }); describe("getAuthorFeed", () => { it("should fetch author feed with filters", async () => { const session = createMockSession({ feed: [], cursor: "next" }); const api = new Api(session); await api.getAuthorFeed("did:plc:author", { filter: "posts_no_replies" }); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.feed.getAuthorFeed")); assert(url.includes("actor=did%3Aplc%3Aauthor")); assert(url.includes("filter=posts_no_replies")); }); }); describe("getActorLikes", () => { it("should fetch actor likes", async () => { const session = createMockSession({ feed: [], cursor: "next" }); const api = new Api(session); await api.getActorLikes("did:plc:user"); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.feed.getActorLikes")); assert(url.includes("actor=did%3Aplc%3Auser")); }); }); describe("getPreferences", () => { it("should fetch preferences", async () => { const session = createMockSession({ preferences: [{ $type: "app.bsky.actor.defs#savedFeedsPrefV2" }], }); const api = new Api(session); const result = await api.getPreferences(); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.actor.getPreferences")); assert.deepEqual(result.length, 1); }); }); describe("updatePreferences", () => { it("should update preferences", async () => { const session = createMockSession({}); const api = new Api(session); const prefs = [ { $type: "app.bsky.actor.defs#savedFeedsPrefV2", items: [] }, ]; await api.updatePreferences(prefs); const { url, options } = session.getLastFetchOptions(); assert(url.includes("app.bsky.actor.putPreferences")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.preferences, prefs); }); }); describe("getLabelers", () => { it("should fetch labelers by DIDs", async () => { const session = createMockSession({ views: [{ creator: { did: "did:plc:labeler1" } }], }); const api = new Api(session); const result = await api.getLabelers(["did:plc:labeler1"]); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.labeler.getServices")); assert(url.includes("detailed=true")); assert.deepEqual(result.length, 1); }); }); describe("getLabeler", () => { it("should fetch single labeler", async () => { const session = createMockSession({ views: [{ creator: { did: "did:plc:labeler1" } }], }); const api = new Api(session); const result = await api.getLabeler("did:plc:labeler1"); assert.deepEqual(result.creator.did, "did:plc:labeler1"); }); }); describe("getSession", () => { it("should fetch session info", async () => { const session = createMockSession({ did: "did:plc:testuser", handle: "test.bsky.social", }); const api = new Api(session); const result = await api.getSession(); const { url } = session.getLastFetchOptions(); assert(url.includes("com.atproto.server.getSession")); assert.deepEqual(result.did, "did:plc:testuser"); }); }); describe("getNumNotifications", () => { it("should fetch unread notification count", async () => { const session = createMockSession({ count: 5 }); const api = new Api(session); const result = await api.getNumNotifications(); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.notification.getUnreadCount")); assert.deepEqual(result, 5); }); }); describe("getChatUnreadCounts", () => { it("should fetch chat unread counts via the chat proxy", async () => { const session = createMockSession({ unreadAcceptedConvos: 4, unreadRequestConvos: 1, }); const api = new Api(session); const result = await api.getChatUnreadCounts(); const { url, options } = session.getLastFetchOptions(); assert(url.includes("chat.bsky.convo.getUnreadCounts")); assert(url.includes("includeGroupChats=true")); assert.deepEqual( options.headers["atproto-proxy"], api.chatAppViewServiceDid, ); assert.deepEqual(result.unreadAcceptedConvos, 4); assert.deepEqual(result.unreadRequestConvos, 1); }); }); describe("getNotifications", () => { it("should fetch notifications", async () => { const session = createMockSession({ notifications: [{ uri: "notif1" }], cursor: "next", }); const api = new Api(session); const result = await api.getNotifications(); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.notification.listNotifications")); assert.deepEqual(result.notifications.length, 1); }); }); describe("markNotificationsAsRead", () => { it("should mark notifications as seen", async () => { const session = createMockSession({}); const api = new Api(session); await api.markNotificationsAsRead(); const { url, options } = session.getLastFetchOptions(); assert(url.includes("app.bsky.notification.updateSeen")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert(body.seenAt !== undefined); }); }); describe("listConvos", () => { it("should list conversations", async () => { const session = createMockSession({ convos: [{ id: "convo1" }], cursor: "next", }); const api = new Api(session); const result = await api.listConvos(); const { url, options } = session.getLastFetchOptions(); assert(url.includes("chat.bsky.convo.listConvos")); assert.deepEqual( options.headers["atproto-proxy"], "did:web:api.bsky.chat#bsky_chat", ); assert.deepEqual(result.convos.length, 1); }); it("should include readState filter when provided", async () => { const session = createMockSession({ convos: [] }); const api = new Api(session); await api.listConvos({ readState: "unread" }); const { url } = session.getLastFetchOptions(); assert(url.includes("readState=unread")); }); }); describe("getConvo", () => { it("should fetch conversation by ID", async () => { const session = createMockSession({ convo: { id: "convo1", members: [] } }); const api = new Api(session); const result = await api.getConvo("convo1"); const { url } = session.getLastFetchOptions(); assert(url.includes("chat.bsky.convo.getConvo")); assert(url.includes("convoId=convo1")); assert.deepEqual(result.convo.id, "convo1"); }); }); describe("getMessages", () => { it("should fetch messages for conversation", async () => { const session = createMockSession({ messages: [{ id: "msg1" }], cursor: "next", }); const api = new Api(session); const result = await api.getMessages("convo1"); const { url } = session.getLastFetchOptions(); assert(url.includes("chat.bsky.convo.getMessages")); assert(url.includes("convoId=convo1")); assert.deepEqual(result.messages.length, 1); }); }); describe("sendMessage", () => { it("should send message to conversation", async () => { const session = createMockSession({ id: "msg1", text: "hello" }); const api = new Api(session); await api.sendMessage("convo1", { text: "hello", facets: [] }); const { url, options } = session.getLastFetchOptions(); assert(url.includes("chat.bsky.convo.sendMessage")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.convoId, "convo1"); assert.deepEqual(body.message.text, "hello"); assert.deepEqual(body.message.embed, undefined); }); it("should include embed when provided", async () => { const session = createMockSession({ id: "msg1", text: "hello" }); const api = new Api(session); const embed = { $type: "app.bsky.embed.record", record: { uri: "at://did:plc:abc/app.bsky.feed.post/3abc", cid: "cid1" }, }; await api.sendMessage("convo1", { text: "hello", facets: [], embed }); const { options } = session.getLastFetchOptions(); const body = JSON.parse(options.body); assert.deepEqual(body.message.embed, embed); }); it("should omit embed when null", async () => { const session = createMockSession({ id: "msg1", text: "hello" }); const api = new Api(session); await api.sendMessage("convo1", { text: "hello", facets: [], embed: null }); const { options } = session.getLastFetchOptions(); const body = JSON.parse(options.body); assert.deepEqual(body.message.embed, undefined); }); }); describe("acceptConvo", () => { it("should accept conversation", async () => { const session = createMockSession({ convo: { id: "convo1" } }); const api = new Api(session); await api.acceptConvo("convo1"); const { url, options } = session.getLastFetchOptions(); assert(url.includes("chat.bsky.convo.acceptConvo")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.convoId, "convo1"); }); }); describe("leaveConvo", () => { it("should leave conversation", async () => { const session = createMockSession({ convo: { id: "convo1" } }); const api = new Api(session); await api.leaveConvo("convo1"); const { url, options } = session.getLastFetchOptions(); assert(url.includes("chat.bsky.convo.leaveConvo")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.convoId, "convo1"); }); }); describe("getConvoAvailability", () => { it("should check conversation availability", async () => { const session = createMockSession({ canChat: true }); const api = new Api(session); const result = await api.getConvoAvailability(["did:plc:member1"]); const { url } = session.getLastFetchOptions(); assert(url.includes("chat.bsky.convo.getConvoAvailability")); assert.deepEqual(result.canChat, true); }); }); describe("getConvoForMembers", () => { it("should get or create conversation for members", async () => { const session = createMockSession({ convo: { id: "convo1" } }); const api = new Api(session); const result = await api.getConvoForMembers([ "did:plc:member1", "did:plc:member2", ]); const { url } = session.getLastFetchOptions(); assert(url.includes("chat.bsky.convo.getConvoForMembers")); assert.deepEqual(result.convo.id, "convo1"); }); }); describe("getChatLogs", () => { it("should fetch chat logs", async () => { const session = createMockSession({ logs: [], cursor: "next" }); const api = new Api(session); const result = await api.getChatLogs({ cursor: "somecursor" }); const { url } = session.getLastFetchOptions(); assert(url.includes("chat.bsky.convo.getLog")); assert(url.includes("cursor=somecursor")); assert.deepEqual(result.cursor, "next"); }); }); describe("markConvoAsRead", () => { it("should mark conversation as read", async () => { const session = createMockSession({}); const api = new Api(session); await api.markConvoAsRead("convo1"); const { url, options } = session.getLastFetchOptions(); assert(url.includes("chat.bsky.convo.updateRead")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.convoId, "convo1"); }); }); describe("addMessageReaction", () => { it("should add reaction to message", async () => { const session = createMockSession({ message: { id: "msg1", reactions: [] }, }); const api = new Api(session); await api.addMessageReaction("convo1", "msg1", "👍"); const { url, options } = session.getLastFetchOptions(); assert(url.includes("chat.bsky.convo.addReaction")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.convoId, "convo1"); assert.deepEqual(body.messageId, "msg1"); assert.deepEqual(body.value, "👍"); }); }); describe("removeMessageReaction", () => { it("should remove reaction from message", async () => { const session = createMockSession({ message: { id: "msg1", reactions: [] }, }); const api = new Api(session); await api.removeMessageReaction("convo1", "msg1", "👍"); const { url, options } = session.getLastFetchOptions(); assert(url.includes("chat.bsky.convo.removeReaction")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.convoId, "convo1"); assert.deepEqual(body.messageId, "msg1"); assert.deepEqual(body.value, "👍"); }); }); describe("getLikes", () => { it("should fetch likes for a post", async () => { const session = createMockSession({ likes: [{ actor: { did: "did:plc:a" } }], cursor: "next", }); const api = new Api(session); const result = await api.getLikes( "at://did:plc:author/app.bsky.feed.post/abc", ); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.feed.getLikes")); assert.deepEqual(result.likes.length, 1); }); }); describe("getQuotes", () => { it("should fetch quotes for a post", async () => { const session = createMockSession({ posts: [{ uri: "quote1" }], cursor: "next", }); const api = new Api(session); const result = await api.getQuotes( "at://did:plc:author/app.bsky.feed.post/abc", ); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.feed.getQuotes")); assert.deepEqual(result.posts.length, 1); }); }); describe("getRepostedBy", () => { it("should fetch users who reposted", async () => { const session = createMockSession({ repostedBy: [{ did: "did:plc:a" }], cursor: "next", }); const api = new Api(session); const result = await api.getRepostedBy( "at://did:plc:author/app.bsky.feed.post/abc", ); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.feed.getRepostedBy")); assert.deepEqual(result.repostedBy.length, 1); }); }); describe("getBookmarks", () => { it("should fetch bookmarks", async () => { const session = createMockSession({ bookmarks: [{ uri: "post1" }], cursor: "next", }); const api = new Api(session); const result = await api.getBookmarks(); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.bookmark.getBookmarks")); assert.deepEqual(result.bookmarks.length, 1); }); }); describe("getFollowers", () => { it("should fetch followers for actor", async () => { const session = createMockSession({ followers: [{ did: "did:plc:a" }], cursor: "next", }); const api = new Api(session); const result = await api.getFollowers("did:plc:user"); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.graph.getFollowers")); assert(url.includes("actor=did%3Aplc%3Auser")); assert.deepEqual(result.followers.length, 1); }); }); describe("getFollows", () => { it("should fetch follows for actor", async () => { const session = createMockSession({ follows: [{ did: "did:plc:a" }], cursor: "next", }); const api = new Api(session); const result = await api.getFollows("did:plc:user"); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.graph.getFollows")); assert(url.includes("actor=did%3Aplc%3Auser")); assert.deepEqual(result.follows.length, 1); }); }); describe("getMutes", () => { it("should fetch muted accounts", async () => { const session = createMockSession({ mutes: [{ did: "did:plc:a" }], cursor: "next", }); const api = new Api(session); const result = await api.getMutes(); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.graph.getMutes")); assert(url.includes("limit=50")); assert.deepEqual(result.mutes.length, 1); assert.deepEqual(result.cursor, "next"); }); it("should pass cursor when provided", async () => { const session = createMockSession({ mutes: [], cursor: "" }); const api = new Api(session); await api.getMutes({ cursor: "abc" }); const { url } = session.getLastFetchOptions(); assert(url.includes("cursor=abc")); }); }); describe("muteActor", () => { it("should mute actor", async () => { const session = createMockSession({}); const api = new Api(session); await api.muteActor("did:plc:target"); const { url, options } = session.getLastFetchOptions(); assert(url.includes("app.bsky.graph.muteActor")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.actor, "did:plc:target"); }); }); describe("unmuteActor", () => { it("should unmute actor", async () => { const session = createMockSession({}); const api = new Api(session); await api.unmuteActor("did:plc:target"); const { url, options } = session.getLastFetchOptions(); assert(url.includes("app.bsky.graph.unmuteActor")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.actor, "did:plc:target"); }); }); describe("blockActor", () => { it("should create block record", async () => { const session = createMockSession({ uri: "at://did:plc:testuser/app.bsky.graph.block/abc", }); const api = new Api(session); const profile = { did: "did:plc:target" }; await api.blockActor(profile); const { url, options } = session.getLastFetchOptions(); assert(url.includes("com.atproto.repo.createRecord")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.collection, "app.bsky.graph.block"); assert.deepEqual(body.record.subject, "did:plc:target"); }); }); describe("unblockActor", () => { it("should delete block record", async () => { const session = createMockSession({}); const api = new Api(session); const profile = { viewer: { blocking: "at://did:plc:testuser/app.bsky.graph.block/blockrkey123", }, }; await api.unblockActor(profile); const { url, options } = session.getLastFetchOptions(); assert(url.includes("com.atproto.repo.deleteRecord")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.collection, "app.bsky.graph.block"); assert.deepEqual(body.rkey, "blockrkey123"); }); }); describe("applyWrites", () => { it("posts the writes batch with validate: true", async () => { const session = createMockSession({}); const api = new Api(session); const writes = [ { $type: "com.atproto.repo.applyWrites#create", collection: "app.bsky.feed.post", rkey: "3kabc123def45", value: { $type: "app.bsky.feed.post", text: "Hello world" }, }, ]; await api.applyWrites(writes); const { url, options } = session.getLastFetchOptions(); assert(url.includes("com.atproto.repo.applyWrites")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.repo, session.did); assert.deepEqual(body.writes, writes); assert.deepEqual(body.validate, true); }); }); describe("deletePost", () => { it("should delete post by URI", async () => { const session = createMockSession({}); const api = new Api(session); const post = { uri: "at://did:plc:testuser/app.bsky.feed.post/postrkey123", }; await api.deletePost(post); const { url, options } = session.getLastFetchOptions(); assert(url.includes("com.atproto.repo.deleteRecord")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.collection, "app.bsky.feed.post"); assert.deepEqual(body.rkey, "postrkey123"); }); }); describe("uploadBlob", () => { it("should upload blob with correct content type", async () => { const session = createMockSession({ blob: { ref: { $link: "blobref123" }, mimeType: "image/png" }, }); const api = new Api(session); const blob = new Blob(["test"], { type: "image/png" }); const result = await api.uploadBlob(blob); const { url, options } = session.getLastFetchOptions(); assert(url.includes("com.atproto.repo.uploadBlob")); assert.deepEqual(options.method, "POST"); assert.deepEqual(options.headers["Content-Type"], "image/png"); assert.deepEqual(result.ref.$link, "blobref123"); }); }); describe("createModerationReport", () => { it("should create moderation report", async () => { const session = createMockSession({ id: 123, reasonType: "com.atproto.moderation.defs#reasonSpam", }); const api = new Api(session); const subject = { $type: "com.atproto.repo.strongRef", uri: "posturi", cid: "postcid", }; await api.createModerationReport({ reasonType: "com.atproto.moderation.defs#reasonSpam", reason: "This is spam", subject, labelerDid: "did:plc:labeler", }); const { url, options } = session.getLastFetchOptions(); assert(url.includes("com.atproto.moderation.createReport")); assert.deepEqual(options.method, "POST"); assert.deepEqual( options.headers["atproto-proxy"], "did:plc:labeler#atproto_labeler", ); const body = JSON.parse(options.body); assert.deepEqual(body.reasonType, "com.atproto.moderation.defs#reasonSpam"); assert.deepEqual(body.reason, "This is spam"); assert.deepEqual(body.subject, subject); }); it("should not include reason when not provided", async () => { const session = createMockSession({ id: 123 }); const api = new Api(session); const subject = { $type: "com.atproto.repo.strongRef", uri: "posturi", cid: "postcid", }; await api.createModerationReport({ reasonType: "com.atproto.moderation.defs#reasonSpam", subject, labelerDid: "did:plc:labeler", }); const { options } = session.getLastFetchOptions(); const body = JSON.parse(options.body); assert(body.reason === undefined); }); }); describe("getBlocks", () => { it("should fetch blocked accounts", async () => { const session = createMockSession({ blocks: [{ did: "did:plc:a" }], cursor: "next", }); const api = new Api(session); const result = await api.getBlocks(); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.graph.getBlocks")); assert(url.includes("limit=50")); assert.deepEqual(result.blocks.length, 1); assert.deepEqual(result.cursor, "next"); }); it("should pass cursor when provided", async () => { const session = createMockSession({ blocks: [], cursor: "" }); const api = new Api(session); await api.getBlocks({ cursor: "abc" }); const { url } = session.getLastFetchOptions(); assert(url.includes("cursor=abc")); }); }); describe("searchFeedGenerators", () => { it("should search popular feed generators", async () => { const session = createMockSession({ feeds: [{ uri: "feed1" }], cursor: "next", }); const api = new Api(session); const result = await api.searchFeedGenerators("news"); const { url, options } = session.getLastFetchOptions(); assert(url.includes("app.bsky.unspecced.getPopularFeedGenerators")); assert(url.includes("query=news")); assert(url.includes("limit=15")); assert.deepEqual( options.headers["atproto-proxy"], "did:web:api.bsky.app#bsky_appview", ); assert.deepEqual(result.feeds.length, 1); }); it("should pass cursor when provided", async () => { const session = createMockSession({ feeds: [] }); const api = new Api(session); await api.searchFeedGenerators("news", { cursor: "abc" }); const { url } = session.getLastFetchOptions(); assert(url.includes("cursor=abc")); }); }); describe("getActorFeeds", () => { it("should fetch feeds created by an actor", async () => { const session = createMockSession({ feeds: [{ uri: "feed1" }], cursor: "next", }); const api = new Api(session); const result = await api.getActorFeeds("did:plc:user"); const { url } = session.getLastFetchOptions(); assert(url.includes("app.bsky.feed.getActorFeeds")); assert(url.includes("actor=did%3Aplc%3Auser")); assert(url.includes("limit=50")); assert.deepEqual(result.feeds.length, 1); }); it("should pass cursor when provided", async () => { const session = createMockSession({ feeds: [] }); const api = new Api(session); await api.getActorFeeds("did:plc:user", { cursor: "abc" }); const { url } = session.getLastFetchOptions(); assert(url.includes("cursor=abc")); }); }); describe("getReposts", () => { it("should fetch repost records and skip failures", async () => { let callCount = 0; const session = { serviceEndpoint: "https://test.example.com", did: "did:plc:testuser", fetch: async (url) => { callCount += 1; if (callCount === 2) { return { ok: false, status: 404, statusText: "Not Found", json: async () => ({ error: "RecordNotFound" }), }; } return { ok: true, json: async () => ({ uri: url, value: { subject: { uri: "post1", cid: "cid1" } }, }), }; }, }; const api = new Api(session); const reposts = await api.getReposts([ "at://did:plc:a/app.bsky.feed.repost/1", "at://did:plc:b/app.bsky.feed.repost/2", "at://did:plc:c/app.bsky.feed.repost/3", ]); assert.deepEqual(reposts.length, 2); }); }); describe("putProfileRecord", () => { it("should put profile record with $type and null swapRecord by default", async () => { const session = createMockSession({ uri: "rec", cid: "cid" }); const api = new Api(session); await api.putProfileRecord({ displayName: "Alice" }); const { url, options } = session.getLastFetchOptions(); assert(url.includes("com.atproto.repo.putRecord")); assert.deepEqual(options.method, "POST"); const body = JSON.parse(options.body); assert.deepEqual(body.repo, "did:plc:testuser"); assert.deepEqual(body.collection, "app.bsky.actor.profile"); assert.deepEqual(body.rkey, "self"); assert.deepEqual(body.record.$type, "app.bsky.actor.profile"); assert.deepEqual(body.record.displayName, "Alice"); assert.deepEqual(body.swapRecord, null); }); it("should include swapRecord cid for conditional write", async () => { const session = createMockSession({ uri: "rec", cid: "cid" }); const api = new Api(session); await api.putProfileRecord({ displayName: "Bob" }, "previouscid"); const { options } = session.getLastFetchOptions(); const body = JSON.parse(options.body); assert.deepEqual(body.swapRecord, "previouscid"); }); }); describe("putActivitySubscription", () => { it("should put activity subscription with subject and subscription", async () => { const session = createMockSession({ subject: "did:plc:target" }); const api = new Api(session); const activitySubscription = { post: true, reply: false }; await api.putActivitySubscription("did:plc:target", activitySubscription); const { url, options } = session.getLastFetchOptions(); assert(url.includes("app.bsky.notification.putActivitySubscription")); assert.deepEqual(options.method, "POST"); assert.deepEqual( options.headers["atproto-proxy"], "did:web:api.bsky.app#bsky_appview", ); const body = JSON.parse(options.body); assert.deepEqual(body.subject, "did:plc:target"); assert.deepEqual(body.activitySubscription, activitySubscription); }); }); describe("getServiceAuthToken", () => { it("should fetch service auth token with aud and lxm", async () => { const session = createMockSession({ token: "service-token-123" }); const api = new Api(session); const token = await api.getServiceAuthToken({ aud: "did:web:video.bsky.app", lxm: "app.bsky.video.getUploadLimits", }); const { url } = session.getLastFetchOptions(); assert(url.includes("com.atproto.server.getServiceAuth")); assert(url.includes("aud=did%3Aweb%3Avideo.bsky.app")); assert(url.includes("lxm=app.bsky.video.getUploadLimits")); assert(url.includes("exp=")); assert.deepEqual(token, "service-token-123"); }); it("should use provided exp value", async () => { const session = createMockSession({ token: "service-token-123" }); const api = new Api(session); await api.getServiceAuthToken({ aud: "did:web:video.bsky.app", lxm: "com.atproto.repo.uploadBlob", exp: 1234567890, }); const { url } = session.getLastFetchOptions(); assert(url.includes("exp=1234567890")); }); }); describe("serviceRequest", () => { let originalFetch = null; beforeEach(() => { originalFetch = globalThis.fetch; }); afterEach(() => { globalThis.fetch = originalFetch; }); it("should send request with bearer token to external service", async () => { const mockFetch = new MockFetch(); mockFetch.__interceptJson("https://video.example.com", { ok: true }); globalThis.fetch = mockFetch; const api = new Api(createMockSession({})); await api.serviceRequest("https://video.example.com/xrpc/some.method", { token: "abc-token", query: { foo: "bar" }, }); const lastCall = mockFetch.calls[0]; assert.deepEqual( lastCall.url, "https://video.example.com/xrpc/some.method?foo=bar", ); assert.deepEqual( lastCall.options.headers.Authorization, "Bearer abc-token", ); assert.deepEqual(lastCall.options.method, "GET"); }); it("should omit Authorization when no token is provided", async () => { const mockFetch = new MockFetch(); mockFetch.__interceptJson("https://video.example.com", { ok: true }); globalThis.fetch = mockFetch; const api = new Api(createMockSession({})); await api.serviceRequest("https://video.example.com/xrpc/some.method"); const lastCall = mockFetch.calls[0]; assert(lastCall.options.headers.Authorization === undefined); }); it("should throw ApiError when response is not ok", async () => { const mockFetch = new MockFetch(); mockFetch.__intercept("https://video.example.com", async () => ({ ok: false, status: 400, statusText: "Bad Request", json: async () => ({ error: "BadRequest" }), })); globalThis.fetch = mockFetch; const api = new Api(createMockSession({})); let thrownError = null; try { await api.serviceRequest("https://video.example.com/xrpc/some.method", { token: "abc", }); } catch (e) { thrownError = e; } assert(thrownError instanceof ApiError); assert.deepEqual(thrownError.status, 400); }); }); describe("getVideoUploadLimits", () => { let originalFetch = null; beforeEach(() => { originalFetch = globalThis.fetch; }); afterEach(() => { globalThis.fetch = originalFetch; }); it("should fetch upload limits using a service auth token", async () => { const session = createMockSession({ token: "video-token" }); const mockFetch = new MockFetch(); mockFetch.__interceptJson("https://video.bsky.app", { canUpload: true, remainingDailyVideos: 5, }); globalThis.fetch = mockFetch; const api = new Api(session); const result = await api.getVideoUploadLimits(); const videoCall = mockFetch.calls[0]; assert(videoCall.url.includes("app.bsky.video.getUploadLimits")); assert.deepEqual( videoCall.options.headers.Authorization, "Bearer video-token", ); assert.deepEqual(result.canUpload, true); assert.deepEqual(result.remainingDailyVideos, 5); }); }); describe("getVideoJobStatus", () => { let originalFetch = null; beforeEach(() => { originalFetch = globalThis.fetch; }); afterEach(() => { globalThis.fetch = originalFetch; }); it("should fetch job status by jobId", async () => { const mockFetch = new MockFetch(); mockFetch.__interceptJson("https://video.bsky.app", { jobStatus: { jobId: "job1", state: "JOB_STATE_COMPLETED" }, }); globalThis.fetch = mockFetch; const api = new Api(createMockSession({})); const result = await api.getVideoJobStatus("job1"); const videoCall = mockFetch.calls[0]; assert(videoCall.url.includes("app.bsky.video.getJobStatus")); assert(videoCall.url.includes("jobId=job1")); assert.deepEqual(result.jobId, "job1"); assert.deepEqual(result.state, "JOB_STATE_COMPLETED"); }); }); describe("uploadVideoBlob", () => { let originalFetch = null; beforeEach(() => { originalFetch = globalThis.fetch; }); afterEach(() => { globalThis.fetch = originalFetch; }); it("should upload video using service auth token and return job data", async () => { const session = createMockSession({ token: "upload-token" }); const mockFetch = new MockFetch(); mockFetch.__interceptJson("https://video.bsky.app", { jobId: "job1", state: "JOB_STATE_CREATED", }); globalThis.fetch = mockFetch; const api = new Api(session); const file = new Blob(["video-bytes"], { type: "video/mp4" }); file.name = "clip.mp4"; const result = await api.uploadVideoBlob(file); const videoCall = mockFetch.calls[0]; assert(videoCall.url.includes("app.bsky.video.uploadVideo")); assert(videoCall.url.includes("did=did%3Aplc%3Atestuser")); assert(videoCall.url.includes("name=clip.mp4")); assert.deepEqual(videoCall.options.method, "POST"); assert.deepEqual( videoCall.options.headers.Authorization, "Bearer upload-token", ); assert.deepEqual(videoCall.options.headers["Content-Type"], "video/mp4"); assert.deepEqual(videoCall.options.body, file); assert.deepEqual(result.jobId, "job1"); }); it("should treat already_exists 409 as success and return existing job", async () => { const session = createMockSession({ token: "upload-token" }); const mockFetch = new MockFetch(); mockFetch.__intercept("https://video.bsky.app", async () => ({ ok: false, status: 409, statusText: "Conflict", json: async () => ({ error: "already_exists", jobId: "existing-job" }), })); globalThis.fetch = mockFetch; const api = new Api(session); const file = new Blob(["video"], { type: "video/mp4" }); file.name = "clip.mp4"; const result = await api.uploadVideoBlob(file); assert.deepEqual(result.error, "already_exists"); assert.deepEqual(result.jobId, "existing-job"); }); it("should re-throw non-409 errors", async () => { const session = createMockSession({ token: "upload-token" }); const mockFetch = new MockFetch(); mockFetch.__intercept("https://video.bsky.app", async () => ({ ok: false, status: 500, statusText: "Server Error", json: async () => ({ error: "InternalError" }), })); globalThis.fetch = mockFetch; const api = new Api(session); const file = new Blob(["video"], { type: "video/mp4" }); file.name = "clip.mp4"; let thrownError = null; try { await api.uploadVideoBlob(file); } catch (e) { thrownError = e; } assert(thrownError instanceof ApiError); assert.deepEqual(thrownError.status, 500); }); });