Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
JavaScript
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075import { 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); });});