// Offline tests for platform quotas and the queue's wait-and-retry behaviour. // Run: node --test connector/test/quota.test.mjs (temporary files only; no network) 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-quota-")); process.env.QUORLA_QUOTA_FILE = path.join(TMP, "quota.json"); process.env.QUORLA_CONNECTIONS_FILE = path.join(TMP, "connections.json"); process.env.QUORLA_SCHEDULE_DIR = path.join(TMP, "schedule"); process.env.DASHBOARD_SECRET = "test-secret-not-real"; process.env.ALLOW_PUBLISH = "true"; const quota = await import("../src/platforms/quota.js"); const P = await import("../src/platforms/index.js"); const sched = await import("../src/schedule.js"); const realFetch = globalThis.fetch; let calls = []; const json = (b, status = 200, headers = {}) => new Response(JSON.stringify(b), { status, headers: { "content-type": "application/json", ...headers } }); before(() => { globalThis.fetch = async (u, init = {}) => { const url = String(u); calls.push({ url, method: init.method || "GET" }); if (url.startsWith("https://api.linkedin.com/rest/posts")) return new Response("", { status: 201, headers: { "x-restli-id": `urn:li:share:${calls.length}` } }); return json({ error: { message: `no fake route ${url}` } }, 599); }; }); after(() => { globalThis.fetch = realFetch; fs.rmSync(TMP, { recursive: true, force: true }); }); const H = 3600000; const reset = () => { try { fs.rmSync(process.env.QUORLA_QUOTA_FILE); } catch { /* none yet */ } }; test("YouTube's day runs midnight to midnight Pacific time, including the DST change", () => { const lim = quota.limitFor("youtube"); const noonPT = Date.parse("2026-10-21T19:00:00Z"); // 12:00 PDT (UTC-7) const w = quota.windowAt(lim, noonPT); assert.equal(new Date(w.start).toISOString(), "2026-10-21T07:00:00.000Z"); assert.equal(new Date(w.end).toISOString(), "2026-10-22T07:00:00.000Z"); const dst = quota.windowAt(lim, Date.parse("2026-11-01T20:00:00Z")); // Nov 1: clocks go back, 25-hour day assert.equal(new Date(dst.start).toISOString(), "2026-11-01T07:00:00.000Z"); assert.equal(new Date(dst.end).toISOString(), "2026-11-02T08:00:00.000Z"); }); test("YouTube allows 6 uploads (6 x 1,600 of 10,000 units), then waits until midnight Pacific", () => { reset(); const at = Date.parse("2026-10-21T19:00:00Z"); for (let i = 0; i < 6; i++) { quota.ensure("youtube", null, "publish", at); quota.record("youtube", null, "publish", at); } const u = quota.usage("youtube", null, at); assert.deepEqual([u.used, u.remaining], [9600, 400]); assert.throws(() => quota.ensure("youtube", null, "publish", at), (e) => e.quota && e.resetAt === "2026-10-22T07:00:00.000Z"); assert.doesNotThrow(() => quota.ensure("youtube", null, "publish", Date.parse("2026-10-22T07:00:01Z")), "new day, new budget"); assert.doesNotThrow(() => quota.ensure("youtube", "someone-else", "read", at), "reads (1 unit) still fit"); }); test("YouTube's budget is shared by all clients; per-account platforms are separate", () => { reset(); const at = Date.now(); for (let i = 0; i < 6; i++) quota.record("youtube", `client-${i}`, "publish", at); assert.throws(() => quota.ensure("youtube", "client-x", "publish", at), /daily limit reached/); for (let i = 0; i < 15; i++) quota.record("tiktok", "acct-a", "publish", at - (15 - i) * 60000); assert.throws(() => quota.ensure("tiktok", "acct-a", "publish", at), (e) => e.quota); assert.doesNotThrow(() => quota.ensure("tiktok", "acct-b", "publish", at), "another TikTok account has its own limit"); }); test("rolling 24 h windows free up when the oldest post ages out", () => { reset(); const at = Date.now(); // relative to now: entries older than 24 h are pruned on every write const first = at - 20 * H; quota.record("tiktok", "acct", "publish", first); for (let i = 1; i < 15; i++) quota.record("tiktok", "acct", "publish", at - H); let err; try { quota.ensure("tiktok", "acct", "publish", at); } catch (e) { err = e; } assert.equal(err.resetAt, new Date(first + 24 * H).toISOString()); assert.doesNotThrow(() => quota.ensure("tiktok", "acct", "publish", first + 24 * H + 1000)); }); test("limits can be changed in .env", () => { reset(); process.env.QUOTA_REDDIT = "2"; quota.record("reddit", "u", "publish"); quota.record("reddit", "u", "publish"); assert.throws(() => quota.ensure("reddit", "u", "publish"), /2\/2 posts/); delete process.env.QUOTA_REDDIT; }); test("platform 'slow down' errors become a retry, other errors don't", () => { const at = Date.parse("2026-10-21T19:00:00Z"); const yt = quota.fromPlatformError("youtube", { message: "YouTube: upload failed", status: 403, body: { error: { errors: [{ reason: "quotaExceeded" }] } } }, at); assert.equal(yt.resetAt, "2026-10-22T07:00:00.000Z"); const tt = quota.fromPlatformError("tiktok", { message: "TikTok: spam_risk_too_many_posts", status: 403 }, at); assert.ok(tt.quota); assert.equal(quota.fromPlatformError("reddit", { message: "Reddit: Too Many Requests", status: 429 }, at).resetAt, new Date(at + 15 * 60000).toISOString()); assert.equal(quota.fromPlatformError("linkedin", { message: "LinkedIn: invalid image", status: 400 }), null); }); test("drafts beyond the daily limit get a warning, not an error", () => { reset(); process.env.QUOTA_LINKEDIN = "1"; P.vault.saveConnection({ client: "falcon-fashions", platform: "linkedin", account: { id: "abc", name: "Pat" }, tokens: { accessToken: "t", expiresAt: new Date(Date.now() + 864e5).toISOString() }, by: "t" }); const when = new Date(Date.now() + 3 * 864e5).toISOString().slice(0, 10) + " 10:00"; const first = sched.createPost({ client: "falcon-fashions", platforms: ["linkedin"], type: "TEXT", caption: "one", localTime: when }); assert.deepEqual(first.warnings, []); const second = sched.createPost({ client: "falcon-fashions", platforms: ["linkedin"], type: "TEXT", caption: "two", localTime: when.replace("10:00", "11:00") }); assert.equal(second.warnings.length, 1); assert.match(second.warnings[0], /more than the 1 allowed/); delete process.env.QUOTA_LINKEDIN; }); test("the queue waits for the reset and then publishes, without failing the post", async () => { reset(); process.env.QUOTA_LINKEDIN = "1"; const post = sched.createPost({ client: "falcon-fashions", platforms: ["linkedin"], type: "TEXT", caption: "Hello", localTime: new Date(Date.now() + 2 * 60000).toLocaleString("sv-SE", { timeZone: "America/Toronto" }).slice(0, 16) }); await sched.approvePost(post.id, "Tester"); quota.record("linkedin", "abc", "publish"); // the day's single post is already used calls = []; const t1 = Date.now() + 3 * 60000; await sched.runDue({ now: t1 }); let p = sched.getPost(post.id); assert.equal(p.status, "approved", "still approved, not failed"); assert.ok(p.retryAt && p.waiting.linkedin, "remembers when to retry"); assert.equal(calls.length, 0, "nothing was sent to LinkedIn"); await sched.runDue({ now: t1 + 60000 }); assert.equal(calls.length, 0, "not retried before the reset"); reset(); // the 24 h window has passed await sched.runDue({ now: new Date(p.retryAt).getTime() + 1000 }); p = sched.getPost(post.id); assert.equal(p.status, "published"); assert.ok(p.results.linkedin.id); assert.ok(!p.retryAt && !p.waiting.linkedin); delete process.env.QUOTA_LINKEDIN; });