Files
xarticleaudio/test/app.test.js

543 lines
14 KiB
JavaScript

"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { buildApp } = require("../src/app");
const { hmacSHA256Hex } = require("../src/lib/signature");
function createApp(options = {}) {
const baseConfig = {
xWebhookSecret: "x-secret",
polarWebhookSecret: "polar-secret",
xBearerToken: "",
xBotUserId: "",
polarAccessToken: "",
polarServer: "production",
polarProductIds: [],
appBaseUrl: "http://localhost:3000",
ttsApiKey: "",
ttsBaseUrl: "",
ttsModel: "gpt-4o-mini-tts",
ttsVoice: "alloy",
s3Bucket: "",
s3Region: "",
s3Endpoint: "",
s3AccessKeyId: "",
s3SecretAccessKey: "",
s3SignedUrlTtlSec: 3600,
rateLimits: {
webhookPerMinute: 120,
authPerMinute: 30,
actionPerMinute: 60,
},
credit: {
baseCredits: 1,
includedChars: 25000,
stepChars: 10000,
stepCredits: 1,
maxCharsPerArticle: 120000,
},
};
const overrideConfig = options.config || {};
const mergedConfig = {
...baseConfig,
...overrideConfig,
rateLimits: {
...baseConfig.rateLimits,
...(overrideConfig.rateLimits || {}),
},
credit: {
...baseConfig.credit,
...(overrideConfig.credit || {}),
},
};
const appOptions = { ...options };
delete appOptions.config;
return buildApp({
config: mergedConfig,
...appOptions,
});
}
async function call(app, { method, path, headers = {}, body = "", query = {} }) {
return app.handleRequest({
method,
path,
headers,
rawBody: body,
query,
});
}
async function postJSONWebhook(app, path, payload, secret, extraHeaders) {
const rawBody = JSON.stringify(payload);
const sig = hmacSHA256Hex(rawBody, secret);
return call(app, {
method: "POST",
path,
headers: { "x-signature": `sha256=${sig}`, ...(extraHeaders || {}) },
body: rawBody,
});
}
test("GET / renders landing page", async () => {
const app = createApp();
const response = await call(app, { method: "GET", path: "/" });
assert.equal(response.status, 200);
assert.match(response.body, /From X Article to audiobook in one mention/);
});
test("GET /assets/styles.css serves compiled stylesheet", async () => {
const app = createApp();
const response = await call(app, { method: "GET", path: "/assets/styles.css" });
assert.equal(response.status, 200);
assert.match(response.headers["content-type"], /text\/css/);
assert.match(response.body, /\.btn/);
});
test("unauthenticated /app redirects to /login with returnTo", async () => {
const app = createApp();
const response = await call(app, { method: "GET", path: "/app" });
assert.equal(response.status, 303);
assert.match(response.headers.location, /^\/login\?/);
assert.match(response.headers.location, /returnTo=%2Fapp/);
});
test("POST /auth/dev-login sets cookie and redirects", async () => {
const app = createApp();
const response = await call(app, {
method: "POST",
path: "/auth/dev-login",
body: "userId=matiss&returnTo=%2Fapp",
});
assert.equal(response.status, 303);
assert.equal(response.headers.location, "/app");
assert.match(response.headers["set-cookie"], /^xartaudio_user=matiss/);
});
test("authenticated dashboard topup + simulate mention flow", async () => {
const app = createApp();
const cookieHeader = "xartaudio_user=alice";
const topup = await call(app, {
method: "POST",
path: "/app/actions/topup",
headers: { cookie: cookieHeader },
body: "amount=8",
});
assert.equal(topup.status, 303);
assert.match(topup.headers.location, /Added%208%20credits/);
const simulate = await call(app, {
method: "POST",
path: "/app/actions/simulate-mention",
headers: { cookie: cookieHeader },
body: "title=Hello&body=This+is+the+article+body",
});
assert.equal(simulate.status, 303);
assert.match(simulate.headers.location, /^\/audio\//);
const dashboard = await call(app, {
method: "GET",
path: "/app",
headers: { cookie: cookieHeader },
});
assert.equal(dashboard.status, 200);
assert.match(dashboard.body, /Recent audiobooks/);
assert.match(dashboard.body, /Hello/);
});
test("audio flow requires auth for unlock and supports permanent unlock", async () => {
const app = createApp();
await call(app, {
method: "POST",
path: "/app/actions/topup",
headers: { cookie: "xartaudio_user=owner" },
body: "amount=5",
});
await call(app, {
method: "POST",
path: "/app/actions/topup",
headers: { cookie: "xartaudio_user=viewer" },
body: "amount=5",
});
const generated = await call(app, {
method: "POST",
path: "/app/actions/simulate-mention",
headers: { cookie: "xartaudio_user=owner" },
body: "title=Owned+Audio&body=Body",
});
const audioPath = generated.headers.location.split("?")[0];
const assetId = audioPath.replace("/audio/", "");
const beforeUnlock = await call(app, {
method: "GET",
path: audioPath,
headers: { cookie: "xartaudio_user=viewer" },
});
assert.match(beforeUnlock.body, /Unlock required: 1 credits/);
const unlock = await call(app, {
method: "POST",
path: `/audio/${assetId}/unlock`,
headers: { cookie: "xartaudio_user=viewer" },
});
assert.equal(unlock.status, 303);
const afterUnlock = await call(app, {
method: "GET",
path: audioPath,
headers: { cookie: "xartaudio_user=viewer" },
});
assert.match(afterUnlock.body, /Access granted/);
const wallet = await call(app, {
method: "GET",
path: "/api/me/wallet",
headers: { cookie: "xartaudio_user=viewer" },
});
const walletData = JSON.parse(wallet.body);
assert.equal(walletData.balance, 4);
});
test("audio page uses signed storage URL when storage adapter is configured", async () => {
const app = createApp({
storageAdapter: {
isConfigured() {
return true;
},
async getSignedDownloadUrl(key) {
return `https://signed.local/${key}`;
},
async uploadAudio() {},
},
});
await call(app, {
method: "POST",
path: "/app/actions/topup",
headers: { cookie: "xartaudio_user=owner" },
body: "amount=2",
});
const generated = await call(app, {
method: "POST",
path: "/app/actions/simulate-mention",
headers: { cookie: "xartaudio_user=owner" },
body: "title=Signed&body=Audio+Body",
});
const audioPath = generated.headers.location.split("?")[0];
const page = await call(app, {
method: "GET",
path: audioPath,
headers: { cookie: "xartaudio_user=owner" },
});
assert.match(page.body, /https:\/\/signed\.local\/audio\/1\.mp3/);
});
test("/api/x/mentions returns upstream mentions when configured", async () => {
const app = createApp({
xAdapter: {
isConfigured() {
return true;
},
async listMentions({ sinceId }) {
return [{ id: "m1", sinceId: sinceId || null }];
},
},
});
const response = await call(app, {
method: "GET",
path: "/api/x/mentions",
query: { sinceId: "100" },
});
assert.equal(response.status, 200);
const body = JSON.parse(response.body);
assert.equal(body.mentions.length, 1);
assert.equal(body.mentions[0].id, "m1");
});
test("simulate mention schedules background audio generation when service is configured", async () => {
const queued = [];
const app = createApp({
audioGenerationService: {
isConfigured() {
return true;
},
async enqueueJob(payload) {
queued.push(payload);
},
},
});
await call(app, {
method: "POST",
path: "/app/actions/topup",
headers: { cookie: "xartaudio_user=alice" },
body: "amount=3",
});
const simulate = await call(app, {
method: "POST",
path: "/app/actions/simulate-mention",
headers: { cookie: "xartaudio_user=alice" },
body: "title=T&body=hello+world",
});
assert.equal(simulate.status, 303);
assert.equal(queued.length, 1);
assert.equal(typeof queued[0].assetId, "string");
assert.equal(queued[0].text, "hello world");
});
test("/api/payments/create-checkout returns 503 when Polar is not configured", async () => {
const app = createApp();
const response = await call(app, {
method: "POST",
path: "/api/payments/create-checkout",
headers: { cookie: "xartaudio_user=viewer" },
});
assert.equal(response.status, 503);
assert.equal(JSON.parse(response.body).error, "polar_checkout_not_configured");
});
test("/api/payments/create-checkout returns checkout URL when adapter is configured", async () => {
const app = createApp({
polarAdapter: {
isConfigured() {
return true;
},
async createCheckoutSession() {
return { id: "chk_1", url: "https://polar.sh/checkout/chk_1" };
},
parseWebhookEvent() {
return null;
},
extractTopUp(payload) {
return payload;
},
},
});
const response = await call(app, {
method: "POST",
path: "/api/payments/create-checkout",
headers: { cookie: "xartaudio_user=buyer" },
});
assert.equal(response.status, 200);
const body = JSON.parse(response.body);
assert.equal(body.checkoutId, "chk_1");
assert.equal(body.checkoutUrl, "https://polar.sh/checkout/chk_1");
});
test("X webhook invalid signature is rejected", async () => {
const app = createApp();
const response = await call(app, {
method: "POST",
path: "/api/webhooks/x",
headers: { "x-signature": "sha256=deadbeef" },
body: JSON.stringify({ mentionPostId: "m1", callerUserId: "u1", parentPost: {} }),
});
assert.equal(response.status, 401);
});
test("X webhook valid flow processes article", async () => {
const app = createApp();
await postJSONWebhook(app, "/api/webhooks/polar", { userId: "u1", credits: 4, eventId: "evt1" }, "polar-secret");
const response = await postJSONWebhook(
app,
"/api/webhooks/x",
{
mentionPostId: "m2",
callerUserId: "u1",
parentPost: {
id: "p2",
article: { id: "a2", title: "T", body: "Hello" },
},
},
"x-secret",
);
assert.equal(response.status, 200);
const body = JSON.parse(response.body);
assert.equal(body.status, "completed");
assert.equal(body.creditsCharged, 1);
});
test("Polar webhook uses adapter parsing for standard webhook headers", async () => {
const app = createApp({
polarAdapter: {
isConfigured() {
return false;
},
async createCheckoutSession() {
return null;
},
parseWebhookEvent() {
return {
type: "order.paid",
data: {
id: "ord_1",
metadata: { xartaudio_user_id: "u9", xartaudio_credits: "7" },
},
};
},
extractTopUp(event) {
return {
userId: event.data.metadata.xartaudio_user_id,
credits: Number.parseInt(event.data.metadata.xartaudio_credits, 10),
eventId: event.data.id,
};
},
},
});
const response = await call(app, {
method: "POST",
path: "/api/webhooks/polar",
headers: {
"webhook-id": "wh_1",
"webhook-timestamp": "1",
"webhook-signature": "sig",
},
body: "{\"type\":\"order.paid\"}",
});
assert.equal(response.status, 200);
const wallet = await call(app, {
method: "GET",
path: "/api/me/wallet",
headers: { cookie: "xartaudio_user=u9" },
});
assert.equal(JSON.parse(wallet.body).balance, 7);
});
test("emits persistence snapshots on mutating actions", async () => {
const snapshots = [];
const app = createApp({
onMutation(state) {
snapshots.push(state);
},
});
await call(app, {
method: "POST",
path: "/app/actions/topup",
headers: { cookie: "xartaudio_user=alice" },
body: "amount=5",
});
await call(app, {
method: "POST",
path: "/app/actions/simulate-mention",
headers: { cookie: "xartaudio_user=alice" },
body: "title=Persisted&body=hello",
});
assert.equal(snapshots.length >= 2, true);
const latest = snapshots[snapshots.length - 1];
assert.equal(latest.version, 1);
assert.equal(typeof latest.updatedAt, "string");
assert.equal(typeof latest.engine, "object");
});
test("can boot app from previously persisted state snapshot", async () => {
const snapshots = [];
const app1 = createApp({
onMutation(state) {
snapshots.push(state);
},
});
await call(app1, {
method: "POST",
path: "/app/actions/topup",
headers: { cookie: "xartaudio_user=restart-user" },
body: "amount=6",
});
const persistedState = snapshots[snapshots.length - 1];
const app2 = createApp({
initialState: persistedState,
});
const wallet = await call(app2, {
method: "GET",
path: "/api/me/wallet",
headers: { cookie: "xartaudio_user=restart-user" },
});
assert.equal(wallet.status, 200);
assert.equal(JSON.parse(wallet.body).balance, 6);
});
test("rate limits repeated webhook calls", async () => {
const app = createApp({
config: {
rateLimits: {
webhookPerMinute: 1,
},
},
});
const first = await call(app, {
method: "POST",
path: "/api/webhooks/x",
headers: { "x-forwarded-for": "1.2.3.4", "x-signature": "sha256=deadbeef" },
body: JSON.stringify({}),
});
const second = await call(app, {
method: "POST",
path: "/api/webhooks/x",
headers: { "x-forwarded-for": "1.2.3.4", "x-signature": "sha256=deadbeef" },
body: JSON.stringify({}),
});
assert.equal(first.status, 401);
assert.equal(second.status, 429);
});
test("rate limits repeated login attempts from same IP", async () => {
const app = createApp({
config: {
rateLimits: {
authPerMinute: 1,
},
},
});
const first = await call(app, {
method: "POST",
path: "/auth/dev-login",
headers: { "x-forwarded-for": "5.5.5.5" },
body: "userId=alice&returnTo=%2Fapp",
});
const second = await call(app, {
method: "POST",
path: "/auth/dev-login",
headers: { "x-forwarded-for": "5.5.5.5" },
body: "userId=alice&returnTo=%2Fapp",
});
assert.equal(first.status, 303);
assert.equal(second.status, 303);
assert.match(second.headers.location, /Too%20many%20requests/);
});