The organizations resource hit LinkedIn's app-wide daily cap; organizationsLookup has its own. Falls back to organizations, then ids. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
380 lines
27 KiB
JavaScript
380 lines
27 KiB
JavaScript
// Offline tests for the platform adapters: every platform API is simulated by a fake fetch that records
|
|
// what Quorla sends. Run: node --test connector/test/ (no network, no real accounts touched)
|
|
import { test, before, after } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-test-"));
|
|
process.env.QUORLA_CONNECTIONS_FILE = path.join(TMP, "connections.json");
|
|
process.env.QUORLA_QUOTA_FILE = path.join(TMP, "quota.json");
|
|
process.env.DASHBOARD_SECRET = "test-secret-not-real";
|
|
process.env.PUBLIC_URL = "https://app.example.test";
|
|
|
|
const { PROJECT_ROOT } = await import("../src/env.js");
|
|
const P = await import("../src/platforms/index.js");
|
|
const { vault } = P;
|
|
const media = await import("../src/platforms/media.js");
|
|
const { littleText } = await import("../src/platforms/linkedin.js");
|
|
const { chunkPlan } = await import("../src/platforms/tiktok.js");
|
|
|
|
// Test media inside data/library (the only folders media links may expose).
|
|
const LIB = path.join(PROJECT_ROOT, "data", "library", "_quorla_test");
|
|
const IMG = path.join(LIB, "1700000000000-abcd-photo.jpg");
|
|
const IMG2 = path.join(LIB, "1700000000001-abcd-photo2.png");
|
|
const VID = path.join(LIB, "1700000000002-abcd-clip.mp4");
|
|
|
|
// ---------- fake fetch ----------
|
|
let calls = [];
|
|
let routes = [];
|
|
const realFetch = globalThis.fetch;
|
|
function route(method, match, reply) { routes.push({ method, match, reply }); }
|
|
function reset() { calls = []; routes = []; }
|
|
const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers } });
|
|
async function fakeFetch(input, init = {}) {
|
|
const url = String(input instanceof URL ? input : input?.url || input);
|
|
const method = (init.method || "GET").toUpperCase();
|
|
let body = init.body;
|
|
if (body instanceof URLSearchParams) body = body.toString();
|
|
calls.push({ method, url, headers: init.headers || {}, body });
|
|
const r = routes.find((x) => x.method === method && (typeof x.match === "string" ? url.startsWith(x.match) : x.match.test(url)));
|
|
if (!r) return json({ error: { message: `no fake route for ${method} ${url}` } }, 599);
|
|
return typeof r.reply === "function" ? r.reply({ url, method, body, headers: init.headers || {} }) : r.reply.clone();
|
|
}
|
|
const sent = (re) => calls.filter((c) => re.test(c.url));
|
|
const bodyJson = (c) => JSON.parse(c.body);
|
|
const form = (c) => Object.fromEntries(new URLSearchParams(c.body));
|
|
|
|
const user = { id: "u1", name: "Tester" };
|
|
async function connect(platform, client, { tokenReply, identify }) {
|
|
vault.saveApp(platform, { clientId: `${platform}-id`, clientSecret: `${platform}-secret` }, "test");
|
|
const url = new URL(P.startConnect({ platform, client, redirectUri: `https://app.example.test/oauth/${platform}/callback`, user }));
|
|
const state = url.searchParams.get("state");
|
|
route("POST", P.adapter(platform).auth.tokenUrl, tokenReply || json({ access_token: `${platform}-at`, refresh_token: `${platform}-rt`, expires_in: 3600, scope: "a b" }));
|
|
for (const [m, match, reply] of identify) route(m, match, reply);
|
|
const conn = await P.finishConnect({ platform, code: "the-code", state, user });
|
|
return { url, conn };
|
|
}
|
|
|
|
before(() => {
|
|
fs.mkdirSync(LIB, { recursive: true });
|
|
fs.writeFileSync(IMG, Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]));
|
|
fs.writeFileSync(IMG2, Buffer.from([0x89, 0x50, 0x4e, 0x47, 4, 5]));
|
|
fs.writeFileSync(VID, Buffer.alloc(1024, 7));
|
|
globalThis.fetch = fakeFetch;
|
|
});
|
|
after(() => {
|
|
globalThis.fetch = realFetch;
|
|
fs.rmSync(LIB, { recursive: true, force: true });
|
|
fs.rmSync(TMP, { recursive: true, force: true });
|
|
});
|
|
|
|
// ---------- vault ----------
|
|
test("vault: secrets are encrypted at rest and never listed", () => {
|
|
vault.saveApp("linkedin", { clientId: "cid", clientSecret: "super-secret-value" }, "t");
|
|
const raw = fs.readFileSync(process.env.QUORLA_CONNECTIONS_FILE, "utf8");
|
|
assert.ok(!raw.includes("super-secret-value") && !raw.includes("cid"), "app secret must not be stored in plain text");
|
|
assert.equal(vault.appCredentials("linkedin").clientSecret, "super-secret-value");
|
|
vault.saveApp("linkedin", { clientId: "cid2", clientSecret: "" }, "t");
|
|
assert.equal(vault.appCredentials("linkedin").clientSecret, "super-secret-value", "empty secret keeps the stored one");
|
|
const c = vault.saveConnection({ client: "acme", platform: "linkedin", account: { id: "1", name: "A" }, tokens: { accessToken: "tok-123" }, by: "t" });
|
|
assert.ok(!JSON.stringify(vault.listConnections()).includes("tok-123"));
|
|
assert.ok(!fs.readFileSync(process.env.QUORLA_CONNECTIONS_FILE, "utf8").includes("tok-123"));
|
|
assert.equal(vault.getConnection(c.id, { withTokens: true }).tokens.accessToken, "tok-123");
|
|
vault.removeConnection(c.id);
|
|
});
|
|
|
|
test("vault: a sign-in state works once and only for the same platform/user", () => {
|
|
const s = vault.createState({ platform: "x", userId: "u1" });
|
|
assert.equal(vault.takeState(s).platform, "x");
|
|
assert.throws(() => vault.takeState(s), /expired or was already used/);
|
|
});
|
|
|
|
// ---------- media links ----------
|
|
test("media links: signed, expiring, limited to library/uploads", () => {
|
|
const u = media.publicMediaUrl(IMG);
|
|
assert.match(u, /^https:\/\/app\.example\.test\/m\/[^/]+\/photo\.jpg$/);
|
|
const token = u.split("/m/")[1].split("/")[0];
|
|
assert.equal(media.resolveMediaToken(token), path.resolve(IMG));
|
|
assert.equal(media.resolveMediaToken(token.slice(0, -2) + "xx"), null, "tampered signature rejected");
|
|
assert.throws(() => media.publicMediaUrl(path.join(PROJECT_ROOT, ".env")), /Only files from the content library/);
|
|
const old = media.publicMediaUrl(IMG, { hours: -1 }).split("/m/")[1].split("/")[0];
|
|
assert.equal(media.resolveMediaToken(old), null, "expired link rejected");
|
|
});
|
|
|
|
// ---------- helpers ----------
|
|
test("LinkedIn little-text escaping keeps hashtags as hashtags", () => {
|
|
assert.equal(littleText("Hi (team) #Waterloo_Eats, see [x]!"), "Hi \\(team\\) {hashtag|\\#|Waterloo_Eats}, see \\[x\\]!");
|
|
assert.equal(littleText("C# 100%"), "C\\# 100%");
|
|
});
|
|
|
|
test("TikTok chunk plan follows TikTok's rules", () => {
|
|
assert.deepEqual(chunkPlan(3 * 1024 * 1024), { chunkSize: 3 * 1024 * 1024, count: 1 });
|
|
const big = 150 * 1024 * 1024;
|
|
const p = chunkPlan(big);
|
|
assert.equal(p.chunkSize, 10 * 1024 * 1024);
|
|
assert.equal(p.count, 15);
|
|
assert.ok(big - (p.count - 1) * p.chunkSize <= 128 * 1024 * 1024, "last chunk stays under 128 MB");
|
|
});
|
|
|
|
// ---------- sign-in URLs ----------
|
|
test("sign-in URLs carry the right client id, scopes and PKCE per platform", () => {
|
|
for (const id of P.PLATFORM_IDS) vault.saveApp(id, { clientId: `${id}-id`, clientSecret: "s" }, "t");
|
|
const u = (id) => new URL(P.startConnect({ platform: id, client: "acme", redirectUri: `https://app.example.test/oauth/${id}/callback`, user }));
|
|
const tt = u("tiktok");
|
|
assert.equal(tt.searchParams.get("client_key"), "tiktok-id");
|
|
assert.match(tt.searchParams.get("scope"), /video\.publish,video\.upload/);
|
|
const yt = u("youtube");
|
|
assert.equal(yt.searchParams.get("access_type"), "offline");
|
|
assert.equal(yt.searchParams.get("code_challenge_method"), "S256");
|
|
assert.ok(yt.searchParams.get("code_challenge"));
|
|
assert.equal(u("reddit").searchParams.get("duration"), "permanent");
|
|
assert.equal(u("linkedin").searchParams.get("redirect_uri"), "https://app.example.test/oauth/linkedin/callback");
|
|
vault.saveApp("linkedin", { clientId: "x", clientSecret: "s", scopes: "openid profile w_member_social" }, "t");
|
|
assert.equal(u("linkedin").searchParams.get("scope"), "openid profile w_member_social", "scope override is used");
|
|
});
|
|
|
|
// ---------- each platform: connect + publish ----------
|
|
test("LinkedIn: connect, list company Pages, post image to a company Page", async () => {
|
|
reset();
|
|
const { conn } = await connect("linkedin", "acme", { identify: [
|
|
["GET", "https://api.linkedin.com/v2/me", json({ id: "abc", localizedFirstName: "Pat", localizedLastName: "Lee" })],
|
|
["GET", "https://api.linkedin.com/rest/organizationAcls", json({ elements: [{ organization: "urn:li:organization:42" }] })],
|
|
["GET", "https://api.linkedin.com/rest/organizationsLookup?ids=List(42)", json({ results: { 42: { localizedName: "Acme Co" } } })],
|
|
] });
|
|
assert.equal(conn.account.name, "Pat Lee");
|
|
const targets = await P.targetsFor(conn.id);
|
|
assert.deepEqual(targets.map((t) => t.id), ["urn:li:person:abc", "urn:li:organization:42"]);
|
|
await P.setTarget(conn.id, "urn:li:organization:42");
|
|
route("POST", "https://api.linkedin.com/rest/images?action=initializeUpload", json({ value: { uploadUrl: "https://upload.linkedin.test/1", image: "urn:li:image:9" } }));
|
|
route("PUT", "https://upload.linkedin.test/1", new Response("", { status: 201 }));
|
|
route("POST", "https://api.linkedin.com/rest/posts", new Response("", { status: 201, headers: { "x-restli-id": "urn:li:share:77" } }));
|
|
const r = await P.publish("linkedin", "acme", { type: "IMAGE", media: [IMG], caption: "New menu #Waterloo" });
|
|
assert.equal(r.id, "urn:li:share:77");
|
|
const post = bodyJson(sent(/rest\/posts/)[0]);
|
|
assert.equal(post.author, "urn:li:organization:42");
|
|
assert.equal(post.content.media.id, "urn:li:image:9");
|
|
assert.match(post.commentary, /\{hashtag\|\\#\|Waterloo\}/);
|
|
assert.ok(sent(/rest\/posts/)[0].headers["LinkedIn-Version"]);
|
|
});
|
|
|
|
test("YouTube: connect a channel, upload a Short with title and privacy", async () => {
|
|
reset();
|
|
const { conn } = await connect("youtube", "acme", { identify: [
|
|
["GET", "https://www.googleapis.com/youtube/v3/channels", json({ items: [{ id: "UC1", snippet: { title: "Acme TV", customUrl: "@acmetv", thumbnails: { default: { url: "t" } } }, statistics: { subscriberCount: "1200" } }] })],
|
|
] });
|
|
assert.equal(conn.account.followers, 1200);
|
|
route("POST", "https://www.googleapis.com/upload/youtube/v3/videos", new Response("", { status: 200, headers: { location: "https://upload.youtube.test/s1" } }));
|
|
route("PUT", "https://upload.youtube.test/s1", json({ id: "vid1", status: { privacyStatus: "unlisted" } }));
|
|
P.validate("youtube", "acme", { type: "REELS", media: [VID], caption: "x", options: { youtube: { title: "Behind the scenes" } } });
|
|
assert.throws(() => P.validate("youtube", "acme", { type: "REELS", media: [VID], caption: "x", options: {} }), /video title is required/);
|
|
assert.throws(() => P.validate("youtube", "acme", { type: "IMAGE", media: [IMG], caption: "x", options: { youtube: { title: "t" } } }), /doesn't take image posts/);
|
|
const r = await P.publish("youtube", "acme", { type: "REELS", media: [VID], caption: "Desc", options: { youtube: { title: "Behind the scenes", privacy: "unlisted" } } });
|
|
assert.equal(r.permalink, "https://www.youtube.com/shorts/vid1");
|
|
const meta = JSON.parse(sent(/upload\/youtube/)[0].body);
|
|
assert.equal(meta.snippet.title, "Behind the scenes");
|
|
assert.equal(meta.status.privacyStatus, "unlisted");
|
|
assert.equal(meta.status.selfDeclaredMadeForKids, false);
|
|
});
|
|
|
|
test("Google Business Profile: auto-picks the only location, posts with a button and photo link", async () => {
|
|
reset();
|
|
const { conn } = await connect("gbp", "acme", { identify: [
|
|
["GET", "https://openidconnect.googleapis.com/v1/userinfo", json({ sub: "g1", email: "owner@acme.test" })],
|
|
["GET", "https://mybusinessaccountmanagement.googleapis.com/v1/accounts", json({ accounts: [{ name: "accounts/1" }] })],
|
|
["GET", "https://mybusinessbusinessinformation.googleapis.com/v1/accounts/1/locations", json({ locations: [{ name: "locations/5", title: "Acme Bakery", storefrontAddress: { addressLines: ["1 King St"], locality: "Waterloo" } }] })],
|
|
] });
|
|
assert.equal(vault.getConnection(conn.id).target.id, "accounts/1/locations/5");
|
|
route("POST", "https://mybusiness.googleapis.com/v4/accounts/1/locations/5/localPosts", json({ name: "accounts/1/locations/5/localPosts/9", searchUrl: "https://g.test/p" }));
|
|
assert.throws(() => P.validate("gbp", "acme", { type: "REELS", media: [VID], caption: "x", options: {} }), /doesn't take video/);
|
|
await assert.rejects(P.publish("gbp", "acme", { type: "IMAGE", media: [IMG], caption: "Fresh bread", options: { gbp: { cta: "ORDER" } } }), /add a link for the button/);
|
|
const r = await P.publish("gbp", "acme", { type: "IMAGE", media: [IMG], caption: "Fresh bread", options: { gbp: { cta: "ORDER", ctaUrl: "https://acme.test/order" } } });
|
|
assert.equal(r.permalink, "https://g.test/p");
|
|
const b = bodyJson(sent(/localPosts/).at(-1));
|
|
assert.deepEqual(b.callToAction, { actionType: "ORDER", url: "https://acme.test/order" });
|
|
assert.match(b.media[0].sourceUrl, /^https:\/\/app\.example\.test\/m\//);
|
|
});
|
|
|
|
test("TikTok: connect, privacy is enforced, video uploads and photo posts pull from signed links", async () => {
|
|
reset();
|
|
const { conn } = await connect("tiktok", "acme", {
|
|
tokenReply: json({ access_token: "tt-at", refresh_token: "tt-rt", expires_in: 86400, open_id: "op1", scope: "video.publish" }),
|
|
identify: [["GET", "https://open.tiktokapis.com/v2/user/info/", json({ data: { user: { open_id: "op1", display_name: "Acme", username: "acme", follower_count: 50 } }, error: { code: "ok" } })]],
|
|
});
|
|
assert.equal(conn.account.username, "acme");
|
|
assert.throws(() => P.validate("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: {} }), /who can view is required/);
|
|
route("POST", "https://open.tiktokapis.com/v2/post/publish/creator_info/query/", json({ data: { privacy_level_options: ["SELF_ONLY"] }, error: { code: "ok" } }));
|
|
await assert.rejects(P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { privacy: "PUBLIC_TO_EVERYONE" } } }), /can't post as/);
|
|
route("POST", "https://open.tiktokapis.com/v2/post/publish/video/init/", json({ data: { publish_id: "pub1", upload_url: "https://upload.tiktok.test/u1" }, error: { code: "ok" } }));
|
|
route("PUT", "https://upload.tiktok.test/u1", new Response("", { status: 201 }));
|
|
route("POST", "https://open.tiktokapis.com/v2/post/publish/status/fetch/", json({ data: { status: "PUBLISH_COMPLETE", publicaly_available_post_id: [7001] }, error: { code: "ok" } }));
|
|
const r = await P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "Our kitchen", options: { tiktok: { privacy: "SELF_ONLY" } } });
|
|
assert.equal(r.permalink, "https://www.tiktok.com/@acme/video/7001");
|
|
const init = bodyJson(sent(/video\/init/)[0]);
|
|
assert.equal(init.source_info.total_chunk_count, 1);
|
|
assert.equal(init.post_info.privacy_level, "SELF_ONLY");
|
|
assert.equal(sent(/upload\.tiktok/)[0].headers["Content-Range"], "bytes 0-1023/1024");
|
|
assert.equal(init.post_info.disable_comment, true, "comments are off unless the person turns them on");
|
|
assert.equal(init.post_info.brand_content_toggle, false);
|
|
// commercial content disclosure rules
|
|
assert.throws(() => P.validate("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { privacy: "SELF_ONLY", discloseCommercial: true } } }), /Choose “Your brand”/);
|
|
assert.throws(() => P.validate("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { privacy: "SELF_ONLY", discloseCommercial: true, brandedContent: true } } }), /can't be private/);
|
|
routes = routes.filter((x) => !String(x.match).includes("creator_info"));
|
|
route("POST", "https://open.tiktokapis.com/v2/post/publish/creator_info/query/", json({ data: { creator_nickname: "Acme", creator_username: "acme", privacy_level_options: ["PUBLIC_TO_EVERYONE", "SELF_ONLY"], comment_disabled: true, max_video_post_duration_sec: 600 }, error: { code: "ok" } }));
|
|
const info = await P.creatorInfo(conn.id);
|
|
assert.deepEqual([info.nickname, info.commentDisabled, info.maxVideoSeconds, info.privacyOptions.length], ["Acme", true, 600, 2]);
|
|
calls = [];
|
|
await P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "Promo", options: { tiktok: { privacy: "PUBLIC_TO_EVERYONE", allowComments: true, discloseCommercial: true, brandOrganic: true } } });
|
|
const promo = bodyJson(sent(/video\/init/)[0]).post_info;
|
|
assert.equal(promo.brand_organic_toggle, true);
|
|
assert.equal(promo.brand_content_toggle, false);
|
|
assert.equal(promo.disable_comment, true, "creator turned comments off in TikTok, so they stay off");
|
|
route("POST", "https://open.tiktokapis.com/v2/post/publish/content/init/", json({ data: { publish_id: "pub2" }, error: { code: "ok" } }));
|
|
await P.publish("tiktok", "acme", { type: "CAROUSEL", media: [IMG, IMG2], caption: "Menu", options: { tiktok: { privacy: "SELF_ONLY", title: "New menu" } } });
|
|
const photo = bodyJson(sent(/content\/init/)[0]);
|
|
assert.equal(photo.media_type, "PHOTO");
|
|
assert.equal(photo.source_info.photo_images.length, 2);
|
|
assert.ok(photo.source_info.photo_images.every((u) => u.startsWith("https://app.example.test/m/")));
|
|
});
|
|
|
|
test("Pinterest: boards, base64 image Pin to the chosen board", async () => {
|
|
reset();
|
|
const { conn } = await connect("pinterest", "acme", { identify: [
|
|
["GET", "https://api.pinterest.com/v5/user_account", json({ username: "acmepins", business_name: "Acme", follower_count: 300 })],
|
|
["GET", "https://api.pinterest.com/v5/boards", json({ items: [{ id: "b1", name: "Cakes" }, { id: "b2", name: "Bread" }] })],
|
|
] });
|
|
assert.equal(calls.find((c) => c.url.startsWith("https://api.pinterest.com/v5/oauth/token")).headers.Authorization.startsWith("Basic "), true, "Pinterest uses Basic auth for tokens");
|
|
assert.equal(vault.getConnection(conn.id).target, null, "two boards: nothing auto-picked");
|
|
assert.throws(() => P.validate("pinterest", "acme", { type: "IMAGE", media: [IMG], caption: "x", options: {} }), /board is required/);
|
|
route("POST", "https://api.pinterest.com/v5/pins", json({ id: "pin9" }));
|
|
const r = await P.publish("pinterest", "acme", { type: "IMAGE", media: [IMG], caption: "Birthday cakes", options: { pinterest: { boardId: "b1", title: "Cakes", link: "https://acme.test" } } });
|
|
assert.equal(r.permalink, "https://www.pinterest.com/pin/pin9/");
|
|
const pin = bodyJson(sent(/v5\/pins/)[0]);
|
|
assert.equal(pin.board_id, "b1");
|
|
assert.equal(pin.media_source.source_type, "image_base64");
|
|
assert.equal(pin.media_source.data, fs.readFileSync(IMG).toString("base64"));
|
|
});
|
|
|
|
test("Threads: long-lived token exchange, text and image posts", async () => {
|
|
reset();
|
|
route("GET", "https://graph.threads.net/access_token", json({ access_token: "th-long", expires_in: 5184000 }));
|
|
const { conn } = await connect("threads", "acme", {
|
|
tokenReply: json({ access_token: "th-short", user_id: 555 }),
|
|
identify: [
|
|
["GET", "https://graph.threads.net/v1.0/me", json({ id: "555", username: "acme" })],
|
|
["GET", "https://graph.threads.net/v1.0/555/threads_insights", json({ data: [{ total_value: { value: 88 } }] })],
|
|
],
|
|
});
|
|
assert.equal(vault.getConnection(conn.id, { withTokens: true }).tokens.accessToken, "th-long");
|
|
assert.equal(conn.account.followers, 88);
|
|
route("POST", "https://graph.threads.net/v1.0/555/threads?", json({ id: "c1" }));
|
|
route("GET", "https://graph.threads.net/v1.0/c1", json({ status: "FINISHED" }));
|
|
route("POST", "https://graph.threads.net/v1.0/555/threads_publish", json({ id: "t1" }));
|
|
route("GET", "https://graph.threads.net/v1.0/t1", json({ permalink: "https://threads.net/@acme/post/t1" }));
|
|
const r = await P.publish("threads", "acme", { type: "IMAGE", media: [IMG], caption: "Hello" });
|
|
assert.equal(r.permalink, "https://threads.net/@acme/post/t1");
|
|
const c = new URL(sent(/555\/threads\?/)[0].url);
|
|
assert.equal(c.searchParams.get("media_type"), "IMAGE");
|
|
assert.match(c.searchParams.get("image_url"), /^https:\/\/app\.example\.test\/m\//);
|
|
assert.throws(() => P.validate("threads", "acme", { type: "TEXT", media: [], caption: "x".repeat(501), options: {} }), /limit is 500/);
|
|
P.validate("threads", "acme", { type: "TEXT", media: [], caption: "x".repeat(501), options: { threads: { caption: "short" } } });
|
|
});
|
|
|
|
test("Reddit: text post needs subreddit and title, sends User-Agent", async () => {
|
|
reset();
|
|
await connect("reddit", "acme", { identify: [["GET", "https://oauth.reddit.com/api/v1/me", json({ id: "r1", name: "acme_bakery", total_karma: 12 })]] });
|
|
assert.throws(() => P.validate("reddit", "acme", { type: "TEXT", media: [], caption: "x", options: { reddit: { subreddit: "waterloo" } } }), /post title is required/);
|
|
route("POST", "https://oauth.reddit.com/api/submit", json({ json: { errors: [], data: { name: "t3_abc", url: "https://reddit.com/r/waterloo/abc" } } }));
|
|
const r = await P.publish("reddit", "acme", { type: "TEXT", media: [], caption: "We're open late", options: { reddit: { subreddit: "r/waterloo", title: "Late hours" } } });
|
|
assert.equal(r.permalink, "https://reddit.com/r/waterloo/abc");
|
|
const sub = sent(/api\/submit/)[0];
|
|
assert.deepEqual({ sr: form(sub).sr, kind: form(sub).kind, text: form(sub).text }, { sr: "waterloo", kind: "self", text: "We're open late" });
|
|
assert.ok(sub.headers["User-Agent"]);
|
|
route("POST", "https://oauth.reddit.com/api/submit", json({ json: { errors: [["SUBREDDIT_NOTALLOWED", "you aren't allowed to post there.", "sr"]] } }));
|
|
routes = routes.filter((x, i, a) => !(x.match === "https://oauth.reddit.com/api/submit" && a.findIndex((y) => y.match === x.match) === i));
|
|
await assert.rejects(P.publish("reddit", "acme", { type: "TEXT", media: [], caption: "x", options: { reddit: { subreddit: "x", title: "t" } } }), /aren't allowed to post/);
|
|
});
|
|
|
|
test("expired tokens are refreshed and saved; failed refresh asks to reconnect", async () => {
|
|
reset();
|
|
const c = vault.connectionFor("acme", "youtube");
|
|
vault.updateConnection(c.id, { tokens: { accessToken: "old", refreshToken: "yt-rt", expiresAt: new Date(Date.now() - 1000).toISOString() } });
|
|
route("POST", "https://oauth2.googleapis.com/token", json({ access_token: "new-at", expires_in: 3600 }));
|
|
const { tokens } = await P.tokensFor(c.id);
|
|
assert.equal(tokens.accessToken, "new-at");
|
|
assert.equal(tokens.refreshToken, "yt-rt", "refresh token kept when the platform doesn't send a new one");
|
|
assert.equal(form(sent(/oauth2\.googleapis/)[0]).grant_type, "refresh_token");
|
|
reset();
|
|
vault.updateConnection(c.id, { tokens: { accessToken: "old", refreshToken: "bad", expiresAt: new Date(Date.now() - 1000).toISOString() } });
|
|
route("POST", "https://oauth2.googleapis.com/token", json({ error: "invalid_grant", error_description: "Token has been revoked." }, 400));
|
|
await assert.rejects(P.tokensFor(c.id), /Reconnect it/);
|
|
assert.equal(vault.getConnection(c.id).status, "reconnect");
|
|
});
|
|
|
|
test("posting to an unconnected platform is refused clearly", () => {
|
|
assert.throws(() => P.validate("linkedin", "nobody", { type: "TEXT", media: [], caption: "x", options: {} }), /isn't connected for this client/);
|
|
});
|
|
|
|
test("vault: a connection can move to another client, but not onto an existing one", () => {
|
|
const tok = { accessToken: "a", refreshToken: "r" };
|
|
const a = vault.saveConnection({ client: "move-from", platform: "tiktok", account: { id: "u1", name: "One" }, tokens: tok, by: "t" });
|
|
vault.saveConnection({ client: "move-taken", platform: "tiktok", account: { id: "u2", name: "Two" }, tokens: tok, by: "t" });
|
|
assert.throws(() => vault.moveConnection(a.id, "move-taken"), /already has an account/);
|
|
const moved = vault.moveConnection(a.id, "move-to", "t");
|
|
assert.equal(moved.client, "move-to");
|
|
assert.equal(vault.connectionFor("move-from", "tiktok"), null);
|
|
assert.equal(vault.connectionFor("move-to", "tiktok", { withTokens: true }).tokens.accessToken, "a");
|
|
});
|
|
|
|
test("TikTok: inbox mode uploads to the creator's inbox with no audience settings", async () => {
|
|
reset();
|
|
route("POST", "https://open.tiktokapis.com/v2/post/publish/creator_info/query/", json({ data: { privacy_level_options: ["SELF_ONLY"] }, error: { code: "ok" } }));
|
|
route("POST", "https://open.tiktokapis.com/v2/post/publish/inbox/video/init/", json({ data: { publish_id: "inb1", upload_url: "https://upload.tiktok.test/i1" }, error: { code: "ok" } }));
|
|
route("PUT", "https://upload.tiktok.test/i1", new Response("", { status: 201 }));
|
|
route("POST", "https://open.tiktokapis.com/v2/post/publish/status/fetch/", json({ data: { status: "SEND_TO_USER_INBOX" }, error: { code: "ok" } }));
|
|
P.validate("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { mode: "inbox" } } });
|
|
const r = await P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { mode: "inbox" } } });
|
|
assert.equal(r.inbox, true);
|
|
assert.equal(sent(/\/video\/init\//).filter((c) => !c.url.includes("inbox")).length, 0, "no direct post");
|
|
assert.equal(bodyJson(sent(/inbox\/video\/init/)[0]).post_info, undefined);
|
|
});
|
|
|
|
test("platform errors include the code and a plain fix", async () => {
|
|
reset();
|
|
route("POST", "https://open.tiktokapis.com/v2/post/publish/creator_info/query/", json({ data: { privacy_level_options: ["SELF_ONLY"] }, error: { code: "ok" } }));
|
|
route("POST", "https://open.tiktokapis.com/v2/post/publish/video/init/", json({ error: { code: "unaudited_client_can_only_post_to_private_accounts", message: "Please review our integration guidelines" } }, 403));
|
|
await assert.rejects(P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { privacy: "SELF_ONLY" } } }), /Private account.*unaudited_client_can_only_post_to_private_accounts/);
|
|
});
|
|
|
|
test("Google reviews: list with stars and replies, reply only to this location's reviews", async () => {
|
|
reset();
|
|
route("GET", "https://mybusiness.googleapis.com/v4/accounts/1/locations/5/reviews", json({ averageRating: 4.6, totalReviewCount: 52, reviews: [
|
|
{ name: "accounts/1/locations/5/reviews/r1", reviewer: { displayName: "Sam" }, starRating: "FIVE", comment: "Best croissants", createTime: "2026-10-01T10:00:00Z" },
|
|
{ name: "accounts/1/locations/5/reviews/r2", reviewer: { isAnonymous: true }, starRating: "TWO", createTime: "2026-10-02T10:00:00Z", reviewReply: { comment: "Sorry!", updateTime: "2026-10-03T10:00:00Z" } },
|
|
] }));
|
|
const r = await P.gbpReviews("acme");
|
|
assert.equal(r.average, 4.6);
|
|
assert.deepEqual(r.reviews.map((x) => [x.stars, x.author, Boolean(x.reply)]), [[5, "Sam", false], [2, "A Google user", true]]);
|
|
route("PUT", "https://mybusiness.googleapis.com/v4/accounts/1/locations/5/reviews/r1/reply", json({ comment: "Thanks Sam!", updateTime: "2026-10-04T10:00:00Z" }));
|
|
assert.equal((await P.gbpReply("acme", "accounts/1/locations/5/reviews/r1", "Thanks Sam!")).text, "Thanks Sam!");
|
|
await assert.rejects(P.gbpReply("acme", "accounts/9/locations/9/reviews/x", "hi"), /isn't on this client's location/);
|
|
await assert.rejects(P.gbpReply("acme", "accounts/1/locations/5/reviews/r1", " "), /Write a reply/);
|
|
});
|
|
|
|
test("YouTube comments: latest threads on the channel with a link to reply", async () => {
|
|
reset();
|
|
delete process.env.YOUTUBE_API_KEY;
|
|
await assert.rejects(P.youtubeComments("acme"), /YOUTUBE_API_KEY/);
|
|
process.env.YOUTUBE_API_KEY = "yt-key";
|
|
route("GET", "https://www.googleapis.com/youtube/v3/commentThreads", json({ items: [{ id: "t1", snippet: { videoId: "v1", totalReplyCount: 2, topLevelComment: { snippet: { authorDisplayName: "Ana", textOriginal: "Love this", publishedAt: "2026-10-05T10:00:00Z", likeCount: 3 } } } }] }));
|
|
const r = await P.youtubeComments("acme");
|
|
delete process.env.YOUTUBE_API_KEY;
|
|
assert.equal(r.comments[0].author, "Ana");
|
|
assert.equal(r.comments[0].url, "https://www.youtube.com/watch?v=v1&lc=t1");
|
|
const call = sent(/commentThreads/)[0];
|
|
assert.match(call.url, /allThreadsRelatedToChannelId=/);
|
|
assert.match(call.url, /key=yt-key/);
|
|
assert.ok(!call.headers?.Authorization, "comments are read with the API key, not the user's token");
|
|
});
|