feat: implement full landing, auth, dashboard, and browser unlock flows
This commit is contained in:
253
src/app.js
253
src/app.js
@@ -1,47 +1,51 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
const { randomUUID } = require("node:crypto");
|
||||||
const { XArtAudioEngine } = require("./lib/engine");
|
const { XArtAudioEngine } = require("./lib/engine");
|
||||||
const { verifySignature } = require("./lib/signature");
|
const { verifySignature } = require("./lib/signature");
|
||||||
const { renderHomePage, renderAudioPage } = require("./views/pages");
|
const {
|
||||||
|
renderLandingPage,
|
||||||
|
renderLoginPage,
|
||||||
|
renderAppPage,
|
||||||
|
renderAudioPage,
|
||||||
|
} = require("./views/pages");
|
||||||
|
const {
|
||||||
|
json,
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
redirect,
|
||||||
|
parseJSON,
|
||||||
|
parseFormUrlEncoded,
|
||||||
|
withQuery,
|
||||||
|
} = require("./lib/http");
|
||||||
|
const {
|
||||||
|
getAuthenticatedUserId,
|
||||||
|
serializeUserCookie,
|
||||||
|
clearUserCookie,
|
||||||
|
} = require("./lib/auth");
|
||||||
|
|
||||||
function json(status, data) {
|
function sanitizeReturnTo(value, fallback = "/app") {
|
||||||
return {
|
if (!value || typeof value !== "string") {
|
||||||
status,
|
return fallback;
|
||||||
headers: { "content-type": "application/json; charset=utf-8" },
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function html(status, markup) {
|
if (!value.startsWith("/")) {
|
||||||
return {
|
return fallback;
|
||||||
status,
|
|
||||||
headers: { "content-type": "text/html; charset=utf-8" },
|
|
||||||
body: markup,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function text(status, body, contentType) {
|
if (value.startsWith("//")) {
|
||||||
return {
|
return fallback;
|
||||||
status,
|
|
||||||
headers: { "content-type": contentType || "text/plain; charset=utf-8" },
|
|
||||||
body,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJSON(rawBody) {
|
return value;
|
||||||
if (!rawBody) {
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
function parsePositiveInt(raw, fallback = null) {
|
||||||
return JSON.parse(rawBody);
|
const parsed = Number.parseInt(String(raw || ""), 10);
|
||||||
} catch {
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
throw new Error("invalid_json");
|
return fallback;
|
||||||
}
|
}
|
||||||
}
|
return parsed;
|
||||||
|
|
||||||
function getUserIdFromHeaders(headers) {
|
|
||||||
return headers["x-user-id"] || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildApp({ config }) {
|
function buildApp({ config }) {
|
||||||
@@ -49,6 +53,17 @@ function buildApp({ config }) {
|
|||||||
creditConfig: config.credit,
|
creditConfig: config.credit,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function ensureAuth(userId, returnTo) {
|
||||||
|
if (userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(withQuery("/login", {
|
||||||
|
returnTo: sanitizeReturnTo(returnTo, "/app"),
|
||||||
|
error: "Please sign in first",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function handleXWebhook(headers, rawBody) {
|
function handleXWebhook(headers, rawBody) {
|
||||||
const signature = headers["x-signature"];
|
const signature = headers["x-signature"];
|
||||||
const isValid = verifySignature({
|
const isValid = verifySignature({
|
||||||
@@ -63,6 +78,7 @@ function buildApp({ config }) {
|
|||||||
|
|
||||||
const payload = parseJSON(rawBody);
|
const payload = parseJSON(rawBody);
|
||||||
|
|
||||||
|
try {
|
||||||
const result = engine.processMention({
|
const result = engine.processMention({
|
||||||
mentionPostId: payload.mentionPostId,
|
mentionPostId: payload.mentionPostId,
|
||||||
callerUserId: payload.callerUserId,
|
callerUserId: payload.callerUserId,
|
||||||
@@ -83,6 +99,9 @@ function buildApp({ config }) {
|
|||||||
creditsCharged: result.job.creditsCharged,
|
creditsCharged: result.job.creditsCharged,
|
||||||
publicLink: result.reply ? result.reply.publicLink : `/audio/${result.job.assetId}`,
|
publicLink: result.reply ? result.reply.publicLink : `/audio/${result.job.assetId}`,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return json(400, { error: error.message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePolarWebhook(headers, rawBody) {
|
function handlePolarWebhook(headers, rawBody) {
|
||||||
@@ -98,23 +117,19 @@ function buildApp({ config }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = parseJSON(rawBody);
|
const payload = parseJSON(rawBody);
|
||||||
|
|
||||||
|
try {
|
||||||
engine.topUpCredits(payload.userId, payload.credits, `polar:${payload.eventId}`);
|
engine.topUpCredits(payload.userId, payload.credits, `polar:${payload.eventId}`);
|
||||||
return json(200, { status: "credited" });
|
return json(200, { status: "credited" });
|
||||||
|
} catch (error) {
|
||||||
|
return json(400, { error: error.message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getJobsForUser(userId) {
|
function handleRequest({ method, path, headers, rawBody, query }) {
|
||||||
if (!userId) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(engine.jobs.values())
|
|
||||||
.filter((job) => job.callerUserId === userId)
|
|
||||||
.sort((a, b) => b.id.localeCompare(a.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRequest({ method, path, headers, rawBody }) {
|
|
||||||
const safeHeaders = headers || {};
|
const safeHeaders = headers || {};
|
||||||
const userId = getUserIdFromHeaders(safeHeaders);
|
const safeQuery = query || {};
|
||||||
|
const userId = getAuthenticatedUserId(safeHeaders);
|
||||||
|
|
||||||
if (method === "GET" && path === "/health") {
|
if (method === "GET" && path === "/health") {
|
||||||
return json(200, { ok: true });
|
return json(200, { ok: true });
|
||||||
@@ -128,8 +143,8 @@ function buildApp({ config }) {
|
|||||||
short_name: "XArtAudio",
|
short_name: "XArtAudio",
|
||||||
start_url: "/",
|
start_url: "/",
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
background_color: "#1d232a",
|
background_color: "#0f172a",
|
||||||
theme_color: "#1d232a",
|
theme_color: "#0f172a",
|
||||||
icons: [],
|
icons: [],
|
||||||
}),
|
}),
|
||||||
"application/manifest+json; charset=utf-8",
|
"application/manifest+json; charset=utf-8",
|
||||||
@@ -145,14 +160,138 @@ function buildApp({ config }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === "GET" && path === "/") {
|
if (method === "GET" && path === "/") {
|
||||||
return html(200, renderHomePage({
|
return html(200, renderLandingPage({ authenticated: Boolean(userId), userId }));
|
||||||
authenticated: Boolean(userId),
|
}
|
||||||
userId,
|
|
||||||
balance: userId ? engine.getWalletBalance(userId) : 0,
|
if (method === "GET" && path === "/login") {
|
||||||
jobs: getJobsForUser(userId),
|
if (userId) {
|
||||||
|
return redirect("/app");
|
||||||
|
}
|
||||||
|
|
||||||
|
return html(200, renderLoginPage({
|
||||||
|
returnTo: sanitizeReturnTo(safeQuery.returnTo, "/app"),
|
||||||
|
error: safeQuery.error || null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (method === "POST" && path === "/auth/dev-login") {
|
||||||
|
const form = parseFormUrlEncoded(rawBody);
|
||||||
|
const requestedUserId = String(form.userId || "").trim();
|
||||||
|
|
||||||
|
if (!/^[a-zA-Z0-9_-]{2,40}$/.test(requestedUserId)) {
|
||||||
|
return redirect(withQuery("/login", {
|
||||||
|
returnTo: sanitizeReturnTo(form.returnTo, "/app"),
|
||||||
|
error: "Username must be 2-40 characters using letters, numbers, _ or -",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPath = sanitizeReturnTo(form.returnTo, "/app");
|
||||||
|
return redirect(nextPath, {
|
||||||
|
"set-cookie": serializeUserCookie(requestedUserId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "POST" && path === "/auth/logout") {
|
||||||
|
return redirect("/", {
|
||||||
|
"set-cookie": clearUserCookie(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "GET" && path === "/app") {
|
||||||
|
const authResponse = ensureAuth(userId, "/app");
|
||||||
|
if (authResponse) {
|
||||||
|
return authResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html(200, renderAppPage({
|
||||||
|
userId,
|
||||||
|
summary: engine.getUserSummary(userId),
|
||||||
|
jobs: engine.listJobsForUser(userId),
|
||||||
|
flash: safeQuery.flash || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "POST" && path === "/app/actions/topup") {
|
||||||
|
const authResponse = ensureAuth(userId, "/app");
|
||||||
|
if (authResponse) {
|
||||||
|
return authResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = parseFormUrlEncoded(rawBody);
|
||||||
|
const amount = parsePositiveInt(form.amount, null);
|
||||||
|
if (!amount || amount > 500) {
|
||||||
|
return redirect(withQuery("/app", { flash: "Invalid credit amount" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.topUpCredits(userId, amount, `app-topup:${userId}:${randomUUID()}`);
|
||||||
|
return redirect(withQuery("/app", { flash: `Added ${amount} credits` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "POST" && path === "/app/actions/simulate-mention") {
|
||||||
|
const authResponse = ensureAuth(userId, "/app");
|
||||||
|
if (authResponse) {
|
||||||
|
return authResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = parseFormUrlEncoded(rawBody);
|
||||||
|
const title = String(form.title || "").trim();
|
||||||
|
const body = String(form.body || "").trim();
|
||||||
|
|
||||||
|
if (!title || !body) {
|
||||||
|
return redirect(withQuery("/app", { flash: "Title and body are required" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = engine.processMention({
|
||||||
|
mentionPostId: `manual:${userId}:${randomUUID()}`,
|
||||||
|
callerUserId: userId,
|
||||||
|
parentPost: {
|
||||||
|
id: `manual-parent:${randomUUID()}`,
|
||||||
|
authorId: userId,
|
||||||
|
article: {
|
||||||
|
id: `manual-article:${randomUUID()}`,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return redirect(withQuery("/app", { flash: "Parent post is not an article" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(withQuery(`/audio/${result.job.assetId}`, {
|
||||||
|
flash: "Audiobook generated",
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
return redirect(withQuery("/app", { flash: `Generation failed: ${error.message}` }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "POST" && path.startsWith("/audio/") && path.endsWith("/unlock")) {
|
||||||
|
const assetId = path.slice("/audio/".length, -"/unlock".length);
|
||||||
|
const authResponse = ensureAuth(userId, `/audio/${assetId}`);
|
||||||
|
if (authResponse) {
|
||||||
|
return authResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
engine.unlockAudio(assetId, userId);
|
||||||
|
return redirect(withQuery(`/audio/${assetId}`, { flash: "Unlocked" }));
|
||||||
|
} catch (error) {
|
||||||
|
return redirect(withQuery(`/audio/${assetId}`, { flash: `Unlock failed: ${error.message}` }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "GET" && path.startsWith("/audio/")) {
|
||||||
|
const assetId = path.slice("/audio/".length);
|
||||||
|
const audio = engine.getAsset(assetId);
|
||||||
|
const accessDecision = audio
|
||||||
|
? engine.checkAudioAccess(assetId, userId)
|
||||||
|
: { allowed: false, reason: "not_found" };
|
||||||
|
return html(200, renderAudioPage({ audio, accessDecision, userId }));
|
||||||
|
}
|
||||||
|
|
||||||
if (method === "POST" && path === "/api/webhooks/x") {
|
if (method === "POST" && path === "/api/webhooks/x") {
|
||||||
return handleXWebhook(safeHeaders, rawBody);
|
return handleXWebhook(safeHeaders, rawBody);
|
||||||
}
|
}
|
||||||
@@ -161,12 +300,6 @@ function buildApp({ config }) {
|
|||||||
return handlePolarWebhook(safeHeaders, rawBody);
|
return handlePolarWebhook(safeHeaders, rawBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === "POST" && path === "/api/dev/topup") {
|
|
||||||
const body = parseJSON(rawBody);
|
|
||||||
engine.topUpCredits(body.userId, body.amount, `dev:${Date.now()}:${body.userId}`);
|
|
||||||
return json(200, { status: "credited" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === "GET" && path === "/api/me/wallet") {
|
if (method === "GET" && path === "/api/me/wallet") {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return json(401, { error: "auth_required" });
|
return json(401, { error: "auth_required" });
|
||||||
@@ -178,6 +311,7 @@ function buildApp({ config }) {
|
|||||||
if (!userId) {
|
if (!userId) {
|
||||||
return json(401, { error: "auth_required" });
|
return json(401, { error: "auth_required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobId = path.slice("/api/jobs/".length);
|
const jobId = path.slice("/api/jobs/".length);
|
||||||
const job = engine.getJob(jobId);
|
const job = engine.getJob(jobId);
|
||||||
if (!job) {
|
if (!job) {
|
||||||
@@ -202,15 +336,6 @@ function buildApp({ config }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === "GET" && path.startsWith("/audio/")) {
|
|
||||||
const assetId = path.slice("/audio/".length);
|
|
||||||
const audio = engine.getAsset(assetId);
|
|
||||||
const accessDecision = audio
|
|
||||||
? engine.checkAudioAccess(assetId, userId)
|
|
||||||
: { allowed: false, reason: "not_found" };
|
|
||||||
return html(200, renderAudioPage({ audio, accessDecision }));
|
|
||||||
}
|
|
||||||
|
|
||||||
return json(404, { error: "not_found" });
|
return json(404, { error: "not_found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
247
test/app.test.js
247
test/app.test.js
@@ -21,66 +21,161 @@ function createApp() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function postJSON(app, path, payload, secret) {
|
function call(app, { method, path, headers = {}, body = "", query = {} }) {
|
||||||
const rawBody = JSON.stringify(payload);
|
|
||||||
const sig = hmacSHA256Hex(rawBody, secret);
|
|
||||||
return app.handleRequest({
|
return app.handleRequest({
|
||||||
method: "POST",
|
method,
|
||||||
path,
|
path,
|
||||||
headers: { "x-signature": `sha256=${sig}` },
|
headers,
|
||||||
rawBody,
|
rawBody: body,
|
||||||
|
query,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test("rejects invalid X webhook signature", () => {
|
function postJSONWebhook(app, path, payload, secret) {
|
||||||
|
const rawBody = JSON.stringify(payload);
|
||||||
|
const sig = hmacSHA256Hex(rawBody, secret);
|
||||||
|
|
||||||
|
return call(app, {
|
||||||
|
method: "POST",
|
||||||
|
path,
|
||||||
|
headers: { "x-signature": `sha256=${sig}` },
|
||||||
|
body: rawBody,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("GET / renders landing page", () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const response = app.handleRequest({
|
const response = 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", () => {
|
||||||
|
const app = createApp();
|
||||||
|
const response = 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", () => {
|
||||||
|
const app = createApp();
|
||||||
|
const response = 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", () => {
|
||||||
|
const app = createApp();
|
||||||
|
const cookieHeader = "xartaudio_user=alice";
|
||||||
|
|
||||||
|
const topup = 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 = 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 = 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", () => {
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
call(app, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/app/actions/topup",
|
||||||
|
headers: { cookie: "xartaudio_user=owner" },
|
||||||
|
body: "amount=5",
|
||||||
|
});
|
||||||
|
call(app, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/app/actions/topup",
|
||||||
|
headers: { cookie: "xartaudio_user=viewer" },
|
||||||
|
body: "amount=5",
|
||||||
|
});
|
||||||
|
|
||||||
|
const generated = 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 = call(app, {
|
||||||
|
method: "GET",
|
||||||
|
path: audioPath,
|
||||||
|
headers: { cookie: "xartaudio_user=viewer" },
|
||||||
|
});
|
||||||
|
assert.match(beforeUnlock.body, /Unlock required: 1 credits/);
|
||||||
|
|
||||||
|
const unlock = call(app, {
|
||||||
|
method: "POST",
|
||||||
|
path: `/audio/${assetId}/unlock`,
|
||||||
|
headers: { cookie: "xartaudio_user=viewer" },
|
||||||
|
});
|
||||||
|
assert.equal(unlock.status, 303);
|
||||||
|
|
||||||
|
const afterUnlock = call(app, {
|
||||||
|
method: "GET",
|
||||||
|
path: audioPath,
|
||||||
|
headers: { cookie: "xartaudio_user=viewer" },
|
||||||
|
});
|
||||||
|
assert.match(afterUnlock.body, /Access granted/);
|
||||||
|
|
||||||
|
const wallet = 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("X webhook invalid signature is rejected", () => {
|
||||||
|
const app = createApp();
|
||||||
|
const response = call(app, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/api/webhooks/x",
|
path: "/api/webhooks/x",
|
||||||
headers: { "x-signature": "sha256=deadbeef" },
|
headers: { "x-signature": "sha256=deadbeef" },
|
||||||
rawBody: JSON.stringify({ mentionPostId: "m1", callerUserId: "u1", parentPost: {} }),
|
body: JSON.stringify({ mentionPostId: "m1", callerUserId: "u1", parentPost: {} }),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(response.status, 401);
|
assert.equal(response.status, 401);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("serves pwa manifest route", () => {
|
test("X webhook valid flow processes article", () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const response = app.handleRequest({
|
|
||||||
method: "GET",
|
|
||||||
path: "/manifest.webmanifest",
|
|
||||||
headers: {},
|
|
||||||
rawBody: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(response.status, 200);
|
postJSONWebhook(app, "/api/webhooks/polar", { userId: "u1", credits: 4, eventId: "evt1" }, "polar-secret");
|
||||||
assert.match(response.headers["content-type"], /application\/manifest\+json/);
|
const response = postJSONWebhook(
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
assert.equal(body.short_name, "XArtAudio");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("X webhook returns not_article response and no charge", () => {
|
|
||||||
const app = createApp();
|
|
||||||
postJSON(app, "/api/webhooks/polar", { userId: "u1", credits: 5, eventId: "evt1" }, "polar-secret");
|
|
||||||
|
|
||||||
const response = postJSON(
|
|
||||||
app,
|
|
||||||
"/api/webhooks/x",
|
|
||||||
{ mentionPostId: "m1", callerUserId: "u1", parentPost: { id: "p1" } },
|
|
||||||
"x-secret",
|
|
||||||
);
|
|
||||||
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
assert.equal(response.status, 200);
|
|
||||||
assert.equal(body.status, "not_article");
|
|
||||||
assert.equal(app.engine.getWalletBalance("u1"), 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("X webhook processes article, charges caller, and exposes audio route", () => {
|
|
||||||
const app = createApp();
|
|
||||||
postJSON(app, "/api/webhooks/polar", { userId: "u1", credits: 5, eventId: "evt2" }, "polar-secret");
|
|
||||||
|
|
||||||
const response = postJSON(
|
|
||||||
app,
|
app,
|
||||||
"/api/webhooks/x",
|
"/api/webhooks/x",
|
||||||
{
|
{
|
||||||
@@ -88,74 +183,14 @@ test("X webhook processes article, charges caller, and exposes audio route", ()
|
|||||||
callerUserId: "u1",
|
callerUserId: "u1",
|
||||||
parentPost: {
|
parentPost: {
|
||||||
id: "p2",
|
id: "p2",
|
||||||
article: {
|
article: { id: "a2", title: "T", body: "Hello" },
|
||||||
id: "a2",
|
|
||||||
title: "Article",
|
|
||||||
body: "Hello from article",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"x-secret",
|
"x-secret",
|
||||||
);
|
);
|
||||||
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
|
const body = JSON.parse(response.body);
|
||||||
assert.equal(body.status, "completed");
|
assert.equal(body.status, "completed");
|
||||||
assert.equal(body.creditsCharged, 1);
|
assert.equal(body.creditsCharged, 1);
|
||||||
|
|
||||||
const audioPageUnauthed = app.handleRequest({
|
|
||||||
method: "GET",
|
|
||||||
path: body.publicLink,
|
|
||||||
headers: {},
|
|
||||||
rawBody: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(audioPageUnauthed.status, 200);
|
|
||||||
assert.match(audioPageUnauthed.body, /Sign in required before playback/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("non-owner can unlock with same credits then access", () => {
|
|
||||||
const app = createApp();
|
|
||||||
postJSON(app, "/api/webhooks/polar", { userId: "u1", credits: 5, eventId: "evt3" }, "polar-secret");
|
|
||||||
postJSON(app, "/api/webhooks/polar", { userId: "u2", credits: 5, eventId: "evt4" }, "polar-secret");
|
|
||||||
|
|
||||||
const makeAudio = postJSON(
|
|
||||||
app,
|
|
||||||
"/api/webhooks/x",
|
|
||||||
{
|
|
||||||
mentionPostId: "m3",
|
|
||||||
callerUserId: "u1",
|
|
||||||
parentPost: {
|
|
||||||
id: "p3",
|
|
||||||
article: {
|
|
||||||
id: "a3",
|
|
||||||
title: "Article",
|
|
||||||
body: "Hello from article",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"x-secret",
|
|
||||||
);
|
|
||||||
|
|
||||||
const { publicLink } = JSON.parse(makeAudio.body);
|
|
||||||
const assetId = publicLink.replace("/audio/", "");
|
|
||||||
|
|
||||||
const unlock = app.handleRequest({
|
|
||||||
method: "POST",
|
|
||||||
path: `/api/audio/${assetId}/unlock`,
|
|
||||||
headers: { "x-user-id": "u2" },
|
|
||||||
rawBody: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(unlock.status, 200);
|
|
||||||
assert.equal(app.engine.getWalletBalance("u2"), 4);
|
|
||||||
|
|
||||||
const pageAfterUnlock = app.handleRequest({
|
|
||||||
method: "GET",
|
|
||||||
path: publicLink,
|
|
||||||
headers: { "x-user-id": "u2" },
|
|
||||||
rawBody: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.match(pageAfterUnlock.body, /Access granted/);
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user