feat: wire async app flows for checkout x mentions signed playback and queued generation
This commit is contained in:
277
test/app.test.js
277
test/app.test.js
@@ -9,6 +9,22 @@ 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,
|
||||
@@ -46,7 +62,7 @@ function createApp(options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function call(app, { method, path, headers = {}, body = "", query = {} }) {
|
||||
async function call(app, { method, path, headers = {}, body = "", query = {} }) {
|
||||
return app.handleRequest({
|
||||
method,
|
||||
path,
|
||||
@@ -56,36 +72,36 @@ function call(app, { method, path, headers = {}, body = "", query = {} }) {
|
||||
});
|
||||
}
|
||||
|
||||
function postJSONWebhook(app, path, payload, secret) {
|
||||
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}` },
|
||||
headers: { "x-signature": `sha256=${sig}`, ...(extraHeaders || {}) },
|
||||
body: rawBody,
|
||||
});
|
||||
}
|
||||
|
||||
test("GET / renders landing page", () => {
|
||||
test("GET / renders landing page", async () => {
|
||||
const app = createApp();
|
||||
const response = call(app, { method: "GET", path: "/" });
|
||||
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("unauthenticated /app redirects to /login with returnTo", () => {
|
||||
test("unauthenticated /app redirects to /login with returnTo", async () => {
|
||||
const app = createApp();
|
||||
const response = call(app, { method: "GET", path: "/app" });
|
||||
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", () => {
|
||||
test("POST /auth/dev-login sets cookie and redirects", async () => {
|
||||
const app = createApp();
|
||||
const response = call(app, {
|
||||
const response = await call(app, {
|
||||
method: "POST",
|
||||
path: "/auth/dev-login",
|
||||
body: "userId=matiss&returnTo=%2Fapp",
|
||||
@@ -96,11 +112,11 @@ test("POST /auth/dev-login sets cookie and redirects", () => {
|
||||
assert.match(response.headers["set-cookie"], /^xartaudio_user=matiss/);
|
||||
});
|
||||
|
||||
test("authenticated dashboard topup + simulate mention flow", () => {
|
||||
test("authenticated dashboard topup + simulate mention flow", async () => {
|
||||
const app = createApp();
|
||||
const cookieHeader = "xartaudio_user=alice";
|
||||
|
||||
const topup = call(app, {
|
||||
const topup = await call(app, {
|
||||
method: "POST",
|
||||
path: "/app/actions/topup",
|
||||
headers: { cookie: cookieHeader },
|
||||
@@ -109,7 +125,7 @@ test("authenticated dashboard topup + simulate mention flow", () => {
|
||||
assert.equal(topup.status, 303);
|
||||
assert.match(topup.headers.location, /Added%208%20credits/);
|
||||
|
||||
const simulate = call(app, {
|
||||
const simulate = await call(app, {
|
||||
method: "POST",
|
||||
path: "/app/actions/simulate-mention",
|
||||
headers: { cookie: cookieHeader },
|
||||
@@ -118,7 +134,7 @@ test("authenticated dashboard topup + simulate mention flow", () => {
|
||||
assert.equal(simulate.status, 303);
|
||||
assert.match(simulate.headers.location, /^\/audio\//);
|
||||
|
||||
const dashboard = call(app, {
|
||||
const dashboard = await call(app, {
|
||||
method: "GET",
|
||||
path: "/app",
|
||||
headers: { cookie: cookieHeader },
|
||||
@@ -128,23 +144,23 @@ test("authenticated dashboard topup + simulate mention flow", () => {
|
||||
assert.match(dashboard.body, /Hello/);
|
||||
});
|
||||
|
||||
test("audio flow requires auth for unlock and supports permanent unlock", () => {
|
||||
test("audio flow requires auth for unlock and supports permanent unlock", async () => {
|
||||
const app = createApp();
|
||||
|
||||
call(app, {
|
||||
await call(app, {
|
||||
method: "POST",
|
||||
path: "/app/actions/topup",
|
||||
headers: { cookie: "xartaudio_user=owner" },
|
||||
body: "amount=5",
|
||||
});
|
||||
call(app, {
|
||||
await call(app, {
|
||||
method: "POST",
|
||||
path: "/app/actions/topup",
|
||||
headers: { cookie: "xartaudio_user=viewer" },
|
||||
body: "amount=5",
|
||||
});
|
||||
|
||||
const generated = call(app, {
|
||||
const generated = await call(app, {
|
||||
method: "POST",
|
||||
path: "/app/actions/simulate-mention",
|
||||
headers: { cookie: "xartaudio_user=owner" },
|
||||
@@ -154,28 +170,28 @@ test("audio flow requires auth for unlock and supports permanent unlock", () =>
|
||||
const audioPath = generated.headers.location.split("?")[0];
|
||||
const assetId = audioPath.replace("/audio/", "");
|
||||
|
||||
const beforeUnlock = call(app, {
|
||||
const beforeUnlock = await call(app, {
|
||||
method: "GET",
|
||||
path: audioPath,
|
||||
headers: { cookie: "xartaudio_user=viewer" },
|
||||
});
|
||||
assert.match(beforeUnlock.body, /Unlock required: 1 credits/);
|
||||
|
||||
const unlock = call(app, {
|
||||
const unlock = await call(app, {
|
||||
method: "POST",
|
||||
path: `/audio/${assetId}/unlock`,
|
||||
headers: { cookie: "xartaudio_user=viewer" },
|
||||
});
|
||||
assert.equal(unlock.status, 303);
|
||||
|
||||
const afterUnlock = call(app, {
|
||||
const afterUnlock = await call(app, {
|
||||
method: "GET",
|
||||
path: audioPath,
|
||||
headers: { cookie: "xartaudio_user=viewer" },
|
||||
});
|
||||
assert.match(afterUnlock.body, /Access granted/);
|
||||
|
||||
const wallet = call(app, {
|
||||
const wallet = await call(app, {
|
||||
method: "GET",
|
||||
path: "/api/me/wallet",
|
||||
headers: { cookie: "xartaudio_user=viewer" },
|
||||
@@ -184,9 +200,146 @@ test("audio flow requires auth for unlock and supports permanent unlock", () =>
|
||||
assert.equal(walletData.balance, 4);
|
||||
});
|
||||
|
||||
test("X webhook invalid signature is rejected", () => {
|
||||
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 = call(app, {
|
||||
|
||||
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" },
|
||||
@@ -196,11 +349,11 @@ test("X webhook invalid signature is rejected", () => {
|
||||
assert.equal(response.status, 401);
|
||||
});
|
||||
|
||||
test("X webhook valid flow processes article", () => {
|
||||
test("X webhook valid flow processes article", async () => {
|
||||
const app = createApp();
|
||||
|
||||
postJSONWebhook(app, "/api/webhooks/polar", { userId: "u1", credits: 4, eventId: "evt1" }, "polar-secret");
|
||||
const response = postJSONWebhook(
|
||||
await postJSONWebhook(app, "/api/webhooks/polar", { userId: "u1", credits: 4, eventId: "evt1" }, "polar-secret");
|
||||
const response = await postJSONWebhook(
|
||||
app,
|
||||
"/api/webhooks/x",
|
||||
{
|
||||
@@ -220,7 +373,55 @@ test("X webhook valid flow processes article", () => {
|
||||
assert.equal(body.creditsCharged, 1);
|
||||
});
|
||||
|
||||
test("emits persistence snapshots on mutating actions", () => {
|
||||
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) {
|
||||
@@ -228,14 +429,14 @@ test("emits persistence snapshots on mutating actions", () => {
|
||||
},
|
||||
});
|
||||
|
||||
call(app, {
|
||||
await call(app, {
|
||||
method: "POST",
|
||||
path: "/app/actions/topup",
|
||||
headers: { cookie: "xartaudio_user=alice" },
|
||||
body: "amount=5",
|
||||
});
|
||||
|
||||
call(app, {
|
||||
await call(app, {
|
||||
method: "POST",
|
||||
path: "/app/actions/simulate-mention",
|
||||
headers: { cookie: "xartaudio_user=alice" },
|
||||
@@ -249,7 +450,7 @@ test("emits persistence snapshots on mutating actions", () => {
|
||||
assert.equal(typeof latest.engine, "object");
|
||||
});
|
||||
|
||||
test("can boot app from previously persisted state snapshot", () => {
|
||||
test("can boot app from previously persisted state snapshot", async () => {
|
||||
const snapshots = [];
|
||||
const app1 = createApp({
|
||||
onMutation(state) {
|
||||
@@ -257,7 +458,7 @@ test("can boot app from previously persisted state snapshot", () => {
|
||||
},
|
||||
});
|
||||
|
||||
call(app1, {
|
||||
await call(app1, {
|
||||
method: "POST",
|
||||
path: "/app/actions/topup",
|
||||
headers: { cookie: "xartaudio_user=restart-user" },
|
||||
@@ -269,7 +470,7 @@ test("can boot app from previously persisted state snapshot", () => {
|
||||
initialState: persistedState,
|
||||
});
|
||||
|
||||
const wallet = call(app2, {
|
||||
const wallet = await call(app2, {
|
||||
method: "GET",
|
||||
path: "/api/me/wallet",
|
||||
headers: { cookie: "xartaudio_user=restart-user" },
|
||||
@@ -279,7 +480,7 @@ test("can boot app from previously persisted state snapshot", () => {
|
||||
assert.equal(JSON.parse(wallet.body).balance, 6);
|
||||
});
|
||||
|
||||
test("rate limits repeated webhook calls", () => {
|
||||
test("rate limits repeated webhook calls", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
rateLimits: {
|
||||
@@ -288,13 +489,13 @@ test("rate limits repeated webhook calls", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const first = call(app, {
|
||||
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 = call(app, {
|
||||
const second = await call(app, {
|
||||
method: "POST",
|
||||
path: "/api/webhooks/x",
|
||||
headers: { "x-forwarded-for": "1.2.3.4", "x-signature": "sha256=deadbeef" },
|
||||
@@ -305,7 +506,7 @@ test("rate limits repeated webhook calls", () => {
|
||||
assert.equal(second.status, 429);
|
||||
});
|
||||
|
||||
test("rate limits repeated login attempts from same IP", () => {
|
||||
test("rate limits repeated login attempts from same IP", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
rateLimits: {
|
||||
@@ -314,13 +515,13 @@ test("rate limits repeated login attempts from same IP", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const first = call(app, {
|
||||
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 = call(app, {
|
||||
const second = await call(app, {
|
||||
method: "POST",
|
||||
path: "/auth/dev-login",
|
||||
headers: { "x-forwarded-for": "5.5.5.5" },
|
||||
|
||||
Reference in New Issue
Block a user