- quota.js: per-platform limits (YouTube 10,000 units/day Pacific per project, Instagram 100 / Threads 250 / TikTok 15 posts per 24 h per account, ...), usage log, .env overrides (QUOTA_<PLATFORM>) - Publishing checks the budget first and records usage; when a platform is out of quota (or answers quota exceeded / 429) the post stays approved and the queue retries after the reset, without re-posting finished platforms - Drafts that exceed a day's limit get a warning - Connections page shows used/limit and when it frees up - Tests for windows (incl. DST), shared vs per-account limits and the wait-and-retry queue flow; docs for quotas and the Google app setup Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
7.3 KiB
JavaScript
134 lines
7.3 KiB
JavaScript
// 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.parse("2026-10-21T12:00:00Z");
|
|
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;
|
|
});
|