quorlasocial/connector/test/quota.test.mjs
metatroncubeswdev 2b667c655a Quota test: use today's YouTube day instead of a fixed date
The fixed 21 Oct timestamps got pruned as stale once that day passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:29:55 -04:00

135 lines
7.5 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.now(); // relative to now: stored entries older than the window are pruned on every write
const day = quota.windowAt(quota.limitFor("youtube"), at);
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 === new Date(day.end).toISOString());
assert.doesNotThrow(() => quota.ensure("youtube", null, "publish", day.end + 1000), "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;
});