feat: add job lifecycle controls abuse policies and retention operations
This commit is contained in:
419
test/app.test.js
419
test/app.test.js
@@ -18,6 +18,7 @@ function createApp(options = {}) {
|
||||
betterAuthSecret: "test-better-auth-secret",
|
||||
betterAuthBasePath: "/api/auth",
|
||||
betterAuthDevPassword: "xartaudio-dev-password",
|
||||
internalApiToken: "",
|
||||
convexDeploymentUrl: "",
|
||||
convexAuthToken: "",
|
||||
convexStateQuery: "state:getLatestSnapshot",
|
||||
@@ -40,6 +41,11 @@ function createApp(options = {}) {
|
||||
authPerMinute: 30,
|
||||
actionPerMinute: 60,
|
||||
},
|
||||
abuse: {
|
||||
maxJobsPerUserPerDay: 0,
|
||||
cooldownSec: 0,
|
||||
denyUserIds: [],
|
||||
},
|
||||
credit: {
|
||||
baseCredits: 1,
|
||||
includedChars: 25000,
|
||||
@@ -57,6 +63,10 @@ function createApp(options = {}) {
|
||||
...baseConfig.rateLimits,
|
||||
...(overrideConfig.rateLimits || {}),
|
||||
},
|
||||
abuse: {
|
||||
...baseConfig.abuse,
|
||||
...(overrideConfig.abuse || {}),
|
||||
},
|
||||
credit: {
|
||||
...baseConfig.credit,
|
||||
...(overrideConfig.credit || {}),
|
||||
@@ -218,6 +228,46 @@ test("audio flow requires auth for unlock and supports permanent unlock", async
|
||||
assert.equal(walletData.balance, 4);
|
||||
});
|
||||
|
||||
test("owner can delete audio while non owner is forbidden", async () => {
|
||||
const app = createApp();
|
||||
await call(app, {
|
||||
method: "POST",
|
||||
path: "/app/actions/topup",
|
||||
headers: { cookie: "xartaudio_user=owner-delete" },
|
||||
body: "amount=5",
|
||||
});
|
||||
|
||||
const generated = await call(app, {
|
||||
method: "POST",
|
||||
path: "/app/actions/simulate-mention",
|
||||
headers: { cookie: "xartaudio_user=owner-delete" },
|
||||
body: "title=Delete+Me&body=Body",
|
||||
});
|
||||
const assetId = generated.headers.location.split("?")[0].replace("/audio/", "");
|
||||
|
||||
const forbidden = await call(app, {
|
||||
method: "DELETE",
|
||||
path: `/api/audio/${assetId}`,
|
||||
headers: { cookie: "xartaudio_user=someone-else" },
|
||||
});
|
||||
assert.equal(forbidden.status, 403);
|
||||
|
||||
const deleted = await call(app, {
|
||||
method: "DELETE",
|
||||
path: `/api/audio/${assetId}`,
|
||||
headers: { cookie: "xartaudio_user=owner-delete" },
|
||||
});
|
||||
assert.equal(deleted.status, 200);
|
||||
assert.equal(JSON.parse(deleted.body).status, "deleted");
|
||||
|
||||
const pageAfterDelete = await call(app, {
|
||||
method: "GET",
|
||||
path: `/audio/${assetId}`,
|
||||
headers: { cookie: "xartaudio_user=owner-delete" },
|
||||
});
|
||||
assert.match(pageAfterDelete.body, /Audio not found/);
|
||||
});
|
||||
|
||||
test("audio page uses signed storage URL when storage adapter is configured", async () => {
|
||||
const app = createApp({
|
||||
storageAdapter: {
|
||||
@@ -312,6 +362,166 @@ test("simulate mention schedules background audio generation when service is con
|
||||
assert.equal(queued[0].text, "hello world");
|
||||
});
|
||||
|
||||
test("failed background generation refunds charged credits", async () => {
|
||||
const app = createApp({
|
||||
audioGenerationService: {
|
||||
isConfigured() {
|
||||
return true;
|
||||
},
|
||||
async enqueueJob(payload) {
|
||||
payload.onFailed(new Error("tts_outage"));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await call(app, {
|
||||
method: "POST",
|
||||
path: "/app/actions/topup",
|
||||
headers: { cookie: "xartaudio_user=alice" },
|
||||
body: "amount=3",
|
||||
});
|
||||
|
||||
await call(app, {
|
||||
method: "POST",
|
||||
path: "/app/actions/simulate-mention",
|
||||
headers: { cookie: "xartaudio_user=alice" },
|
||||
body: "title=T&body=hello+world",
|
||||
});
|
||||
|
||||
const wallet = await call(app, {
|
||||
method: "GET",
|
||||
path: "/api/me/wallet",
|
||||
headers: { cookie: "xartaudio_user=alice" },
|
||||
});
|
||||
assert.equal(JSON.parse(wallet.body).balance, 3);
|
||||
});
|
||||
|
||||
test("internal worker endpoints require token and can complete jobs", async () => {
|
||||
const queued = [];
|
||||
const app = createApp({
|
||||
config: {
|
||||
internalApiToken: "internal-token",
|
||||
},
|
||||
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=2",
|
||||
});
|
||||
await call(app, {
|
||||
method: "POST",
|
||||
path: "/app/actions/simulate-mention",
|
||||
headers: { cookie: "xartaudio_user=alice" },
|
||||
body: "title=Queued&body=hello",
|
||||
});
|
||||
|
||||
const job = app.engine.listJobsForUser("alice")[0];
|
||||
assert.equal(job.status, "synthesizing");
|
||||
|
||||
const denied = await call(app, {
|
||||
method: "POST",
|
||||
path: `/internal/jobs/${job.id}/complete`,
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
assert.equal(denied.status, 401);
|
||||
|
||||
const completed = await call(app, {
|
||||
method: "POST",
|
||||
path: `/internal/jobs/${job.id}/complete`,
|
||||
headers: { "x-internal-token": "internal-token" },
|
||||
body: JSON.stringify({
|
||||
asset: {
|
||||
storageKey: "audio/worker.mp3",
|
||||
sizeBytes: 999,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(completed.status, 200);
|
||||
const completedBody = JSON.parse(completed.body);
|
||||
assert.equal(completedBody.job.status, "completed");
|
||||
|
||||
const readJob = await call(app, {
|
||||
method: "GET",
|
||||
path: `/api/jobs/${job.id}`,
|
||||
headers: { cookie: "xartaudio_user=alice" },
|
||||
});
|
||||
assert.equal(readJob.status, 200);
|
||||
assert.equal(JSON.parse(readJob.body).job.status, "completed");
|
||||
});
|
||||
|
||||
test("internal retention endpoint prunes stale content and assets", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
internalApiToken: "internal-token",
|
||||
},
|
||||
});
|
||||
|
||||
await call(app, {
|
||||
method: "POST",
|
||||
path: "/app/actions/topup",
|
||||
headers: { cookie: "xartaudio_user=retention-owner" },
|
||||
body: "amount=2",
|
||||
});
|
||||
await call(app, {
|
||||
method: "POST",
|
||||
path: "/app/actions/simulate-mention",
|
||||
headers: { cookie: "xartaudio_user=retention-owner" },
|
||||
body: "title=Retention&body=Body",
|
||||
});
|
||||
|
||||
const job = app.engine.listJobsForUser("retention-owner")[0];
|
||||
const asset = app.engine.getAsset(job.assetId, { includeDeleted: true });
|
||||
job.createdAt = "2020-01-01T00:00:00.000Z";
|
||||
asset.createdAt = "2020-01-01T00:00:00.000Z";
|
||||
|
||||
const denied = await call(app, {
|
||||
method: "POST",
|
||||
path: "/internal/retention/run",
|
||||
body: JSON.stringify({ rawArticleHours: 1, audioDays: 1 }),
|
||||
});
|
||||
assert.equal(denied.status, 401);
|
||||
|
||||
const run = await call(app, {
|
||||
method: "POST",
|
||||
path: "/internal/retention/run",
|
||||
headers: { "x-internal-token": "internal-token" },
|
||||
body: JSON.stringify({ rawArticleHours: 1, audioDays: 1 }),
|
||||
});
|
||||
assert.equal(run.status, 200);
|
||||
const summary = JSON.parse(run.body).summary;
|
||||
assert.equal(summary.prunedArticleBodies >= 1, true);
|
||||
assert.equal(summary.deletedAssets >= 1, true);
|
||||
|
||||
const page = await call(app, {
|
||||
method: "GET",
|
||||
path: `/audio/${job.assetId}`,
|
||||
headers: { cookie: "xartaudio_user=retention-owner" },
|
||||
});
|
||||
assert.match(page.body, /Audio not found/);
|
||||
});
|
||||
|
||||
test("internal endpoints are disabled when no token configured", async () => {
|
||||
const app = createApp();
|
||||
const response = await call(app, {
|
||||
method: "POST",
|
||||
path: "/internal/retention/run",
|
||||
body: "{}",
|
||||
});
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal(JSON.parse(response.body).error, "internal_api_disabled");
|
||||
});
|
||||
|
||||
test("/api/payments/create-checkout returns 503 when Polar is not configured", async () => {
|
||||
const app = createApp();
|
||||
|
||||
@@ -391,6 +601,125 @@ test("X webhook valid flow processes article", async () => {
|
||||
assert.equal(body.creditsCharged, 1);
|
||||
});
|
||||
|
||||
test("X webhook supports CRC challenge response", async () => {
|
||||
const app = createApp();
|
||||
const response = await call(app, {
|
||||
method: "GET",
|
||||
path: "/api/webhooks/x",
|
||||
query: { crc_token: "token-123" },
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const body = JSON.parse(response.body);
|
||||
assert.match(body.response_token, /^sha256=/);
|
||||
});
|
||||
|
||||
test("X webhook can normalize mentionTweetId payload and reply via adapter", async () => {
|
||||
const replies = [];
|
||||
const app = createApp({
|
||||
xAdapter: {
|
||||
isConfigured() {
|
||||
return true;
|
||||
},
|
||||
async listMentions() {
|
||||
return [];
|
||||
},
|
||||
async fetchParentPostFromMention() {
|
||||
return {
|
||||
id: "parent-1",
|
||||
authorId: "author-1",
|
||||
article: { id: "article-1", title: "From X", body: "Article body" },
|
||||
};
|
||||
},
|
||||
async replyToMention(payload) {
|
||||
replies.push(payload);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await postJSONWebhook(app, "/api/webhooks/polar", { userId: "u10", credits: 5, eventId: "evt10" }, "polar-secret");
|
||||
const response = await postJSONWebhook(app, "/api/webhooks/x", {
|
||||
mentionTweetId: "mention-10",
|
||||
callerUserId: "u10",
|
||||
}, "x-secret");
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const body = JSON.parse(response.body);
|
||||
assert.equal(body.status, "completed");
|
||||
assert.equal(body.replied, true);
|
||||
assert.equal(replies.length, 1);
|
||||
assert.equal(replies[0].mentionTweetId, "mention-10");
|
||||
assert.match(replies[0].text, /audiobook/i);
|
||||
});
|
||||
|
||||
test("X webhook replies with not article message when parent is not article", async () => {
|
||||
const replies = [];
|
||||
const app = createApp({
|
||||
xAdapter: {
|
||||
isConfigured() {
|
||||
return true;
|
||||
},
|
||||
async listMentions() {
|
||||
return [];
|
||||
},
|
||||
async fetchParentPostFromMention() {
|
||||
return { id: "parent-2", text: "not article" };
|
||||
},
|
||||
async replyToMention(payload) {
|
||||
replies.push(payload);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await postJSONWebhook(app, "/api/webhooks/polar", { userId: "u11", credits: 5, eventId: "evt11" }, "polar-secret");
|
||||
const response = await postJSONWebhook(app, "/api/webhooks/x", {
|
||||
mentionTweetId: "mention-11",
|
||||
callerUserId: "u11",
|
||||
}, "x-secret");
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const body = JSON.parse(response.body);
|
||||
assert.equal(body.status, "not_article");
|
||||
assert.equal(body.replied, true);
|
||||
assert.equal(replies.length, 1);
|
||||
assert.match(replies[0].text, /not an X Article/);
|
||||
});
|
||||
|
||||
test("X webhook can normalize tweet_create_events payload", async () => {
|
||||
const app = createApp({
|
||||
xAdapter: {
|
||||
isConfigured() {
|
||||
return true;
|
||||
},
|
||||
async listMentions() {
|
||||
return [];
|
||||
},
|
||||
async fetchParentPostFromMention(mentionTweetId) {
|
||||
assert.equal(mentionTweetId, "mention-evt-1");
|
||||
return {
|
||||
id: "parent-evt-1",
|
||||
authorId: "author-evt-1",
|
||||
article: { id: "article-evt-1", title: "Evt", body: "Body" },
|
||||
};
|
||||
},
|
||||
async replyToMention() {},
|
||||
},
|
||||
});
|
||||
|
||||
await postJSONWebhook(app, "/api/webhooks/polar", { userId: "u-evt", credits: 4, eventId: "evt-seed" }, "polar-secret");
|
||||
const response = await postJSONWebhook(app, "/api/webhooks/x", {
|
||||
tweet_create_events: [
|
||||
{
|
||||
id_str: "mention-evt-1",
|
||||
user: { id_str: "u-evt" },
|
||||
},
|
||||
],
|
||||
}, "x-secret");
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(JSON.parse(response.body).status, "completed");
|
||||
});
|
||||
|
||||
test("Polar webhook uses adapter parsing for standard webhook headers", async () => {
|
||||
const app = createApp({
|
||||
polarAdapter: {
|
||||
@@ -524,6 +853,96 @@ test("rate limits repeated webhook calls", async () => {
|
||||
assert.equal(second.status, 429);
|
||||
});
|
||||
|
||||
test("anti abuse deny list blocks webhook generation", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
abuse: {
|
||||
denyUserIds: ["blocked-user"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await postJSONWebhook(app, "/api/webhooks/x", {
|
||||
mentionPostId: "m-deny",
|
||||
callerUserId: "blocked-user",
|
||||
parentPost: {
|
||||
id: "p-deny",
|
||||
article: { id: "a-deny", title: "T", body: "hello" },
|
||||
},
|
||||
}, "x-secret");
|
||||
|
||||
assert.equal(response.status, 429);
|
||||
assert.equal(JSON.parse(response.body).error, "user_denied");
|
||||
});
|
||||
|
||||
test("anti abuse daily limit blocks second generated job", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
abuse: {
|
||||
maxJobsPerUserPerDay: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await postJSONWebhook(app, "/api/webhooks/polar", { userId: "u-limit", credits: 4, eventId: "evt-limit" }, "polar-secret");
|
||||
const first = await postJSONWebhook(app, "/api/webhooks/x", {
|
||||
mentionPostId: "m-limit-1",
|
||||
callerUserId: "u-limit",
|
||||
parentPost: {
|
||||
id: "p-limit-1",
|
||||
article: { id: "a-limit-1", title: "T1", body: "hello" },
|
||||
},
|
||||
}, "x-secret");
|
||||
|
||||
const second = await postJSONWebhook(app, "/api/webhooks/x", {
|
||||
mentionPostId: "m-limit-2",
|
||||
callerUserId: "u-limit",
|
||||
parentPost: {
|
||||
id: "p-limit-2",
|
||||
article: { id: "a-limit-2", title: "T2", body: "hello" },
|
||||
},
|
||||
}, "x-secret");
|
||||
|
||||
assert.equal(first.status, 200);
|
||||
assert.equal(second.status, 429);
|
||||
assert.equal(JSON.parse(second.body).error, "daily_limit_exceeded");
|
||||
});
|
||||
|
||||
test("anti abuse cooldown reports retry delay", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
abuse: {
|
||||
cooldownSec: 60,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await postJSONWebhook(app, "/api/webhooks/polar", { userId: "u-cool", credits: 4, eventId: "evt-cool" }, "polar-secret");
|
||||
const first = await postJSONWebhook(app, "/api/webhooks/x", {
|
||||
mentionPostId: "m-cool-1",
|
||||
callerUserId: "u-cool",
|
||||
parentPost: {
|
||||
id: "p-cool-1",
|
||||
article: { id: "a-cool-1", title: "T1", body: "hello" },
|
||||
},
|
||||
}, "x-secret");
|
||||
|
||||
const second = await postJSONWebhook(app, "/api/webhooks/x", {
|
||||
mentionPostId: "m-cool-2",
|
||||
callerUserId: "u-cool",
|
||||
parentPost: {
|
||||
id: "p-cool-2",
|
||||
article: { id: "a-cool-2", title: "T2", body: "hello" },
|
||||
},
|
||||
}, "x-secret");
|
||||
|
||||
assert.equal(first.status, 200);
|
||||
assert.equal(second.status, 429);
|
||||
const body = JSON.parse(second.body);
|
||||
assert.equal(body.error, "cooldown_active");
|
||||
assert.equal(typeof body.retryAfterSec, "number");
|
||||
});
|
||||
|
||||
test("rate limits repeated login attempts from same IP", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
|
||||
@@ -31,25 +31,29 @@ function withTempEnv(patch, run) {
|
||||
|
||||
test("config uses defaults when env is missing", () => {
|
||||
withTempEnv({
|
||||
PORT: undefined,
|
||||
LOG_LEVEL: undefined,
|
||||
APP_BASE_URL: undefined,
|
||||
BETTER_AUTH_SECRET: undefined,
|
||||
BETTER_AUTH_BASE_PATH: undefined,
|
||||
QWEN_TTS_MODEL: undefined,
|
||||
MINIO_SIGNED_URL_TTL_SEC: undefined,
|
||||
MINIO_USE_SSL: undefined,
|
||||
WEBHOOK_RPM: undefined,
|
||||
PORT: "",
|
||||
LOG_LEVEL: "",
|
||||
APP_BASE_URL: "",
|
||||
BETTER_AUTH_SECRET: "",
|
||||
BETTER_AUTH_BASE_PATH: "",
|
||||
QWEN_TTS_MODEL: "",
|
||||
MINIO_SIGNED_URL_TTL_SEC: "",
|
||||
MINIO_USE_SSL: "",
|
||||
WEBHOOK_RPM: "",
|
||||
}, () => {
|
||||
const { config } = require("../src/config");
|
||||
assert.equal(config.port, 3000);
|
||||
assert.equal(config.logLevel, "info");
|
||||
assert.equal(config.appBaseUrl, "http://localhost:3000");
|
||||
assert.equal(config.betterAuthBasePath, "/api/auth");
|
||||
assert.equal(config.internalApiToken, "");
|
||||
assert.equal(config.qwenTtsModel, "qwen-tts-latest");
|
||||
assert.equal(config.minioSignedUrlTtlSec, 3600);
|
||||
assert.equal(config.minioUseSSL, true);
|
||||
assert.equal(config.rateLimits.webhookPerMinute, 120);
|
||||
assert.equal(config.abuse.maxJobsPerUserPerDay, 0);
|
||||
assert.equal(config.abuse.cooldownSec, 0);
|
||||
assert.deepEqual(config.abuse.denyUserIds, []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +65,7 @@ test("config reads convex/qwen/minio overrides", () => {
|
||||
BETTER_AUTH_SECRET: "prod-secret",
|
||||
BETTER_AUTH_BASE_PATH: "/api/auth",
|
||||
BETTER_AUTH_DEV_PASSWORD: "xartaudio-dev-password",
|
||||
INTERNAL_API_TOKEN: "internal-token",
|
||||
CONVEX_DEPLOYMENT_URL: "https://example.convex.cloud",
|
||||
CONVEX_AUTH_TOKEN: "convex-token",
|
||||
CONVEX_STATE_QUERY: "state:get",
|
||||
@@ -72,12 +77,16 @@ test("config reads convex/qwen/minio overrides", () => {
|
||||
MINIO_BUCKET: "audio",
|
||||
MINIO_SIGNED_URL_TTL_SEC: "7200",
|
||||
WEBHOOK_RPM: "77",
|
||||
ABUSE_MAX_JOBS_PER_USER_PER_DAY: "5",
|
||||
ABUSE_COOLDOWN_SEC: "120",
|
||||
ABUSE_DENY_USER_IDS: "u1,u2",
|
||||
}, () => {
|
||||
const { config } = require("../src/config");
|
||||
assert.equal(config.port, 8080);
|
||||
assert.equal(config.logLevel, "debug");
|
||||
assert.equal(config.appBaseUrl, "https://xartaudio.app");
|
||||
assert.equal(config.betterAuthSecret, "prod-secret");
|
||||
assert.equal(config.internalApiToken, "internal-token");
|
||||
assert.equal(config.convexDeploymentUrl, "https://example.convex.cloud");
|
||||
assert.equal(config.convexAuthToken, "convex-token");
|
||||
assert.equal(config.convexStateQuery, "state:get");
|
||||
@@ -89,5 +98,8 @@ test("config reads convex/qwen/minio overrides", () => {
|
||||
assert.equal(config.minioBucket, "audio");
|
||||
assert.equal(config.minioSignedUrlTtlSec, 7200);
|
||||
assert.equal(config.rateLimits.webhookPerMinute, 77);
|
||||
assert.equal(config.abuse.maxJobsPerUserPerDay, 5);
|
||||
assert.equal(config.abuse.cooldownSec, 120);
|
||||
assert.deepEqual(config.abuse.denyUserIds, ["u1", "u2"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ test("returns not_article and does not charge caller", () => {
|
||||
assert.equal(engine.getWalletBalance("u1"), 5);
|
||||
});
|
||||
|
||||
test("charges credits and creates completed job for valid article", () => {
|
||||
test("charges credits and creates charged job for valid article", () => {
|
||||
const engine = createEngine();
|
||||
engine.topUpCredits("u1", 5, "topup-2");
|
||||
|
||||
@@ -50,7 +50,7 @@ test("charges credits and creates completed job for valid article", () => {
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.job.status, "completed");
|
||||
assert.equal(result.job.status, "charged");
|
||||
assert.equal(result.job.creditsCharged, 1);
|
||||
assert.equal(engine.getWalletBalance("u1"), 4);
|
||||
|
||||
@@ -150,11 +150,60 @@ test("lists jobs for user newest first and provides summary", () => {
|
||||
|
||||
const summary = engine.getUserSummary("u1");
|
||||
assert.equal(summary.totalJobs, 2);
|
||||
assert.equal(summary.completedJobs, 2);
|
||||
assert.equal(summary.completedJobs, 0);
|
||||
assert.equal(summary.totalCreditsSpent, 2);
|
||||
assert.equal(summary.balance, 8);
|
||||
});
|
||||
|
||||
test("job can transition through start and completion states", () => {
|
||||
const engine = createEngine();
|
||||
engine.topUpCredits("u1", 5, "topup-transition");
|
||||
|
||||
const created = engine.processMention({
|
||||
mentionPostId: "m-transition",
|
||||
callerUserId: "u1",
|
||||
parentPost: {
|
||||
id: "p-transition",
|
||||
article: { id: "a-transition", title: "T", body: "hello world" },
|
||||
},
|
||||
});
|
||||
|
||||
const started = engine.startJob(created.job.id);
|
||||
assert.equal(started.status, "synthesizing");
|
||||
|
||||
const completed = engine.completeJob(created.job.id, {
|
||||
storageKey: "audio/final.mp3",
|
||||
sizeBytes: 42,
|
||||
});
|
||||
assert.equal(completed.status, "completed");
|
||||
assert.equal(engine.getAsset(created.job.assetId).storageKey, "audio/final.mp3");
|
||||
assert.equal(engine.getAsset(created.job.assetId).sizeBytes, 42);
|
||||
});
|
||||
|
||||
test("failed generation can refund caller credits once", () => {
|
||||
const engine = createEngine();
|
||||
engine.topUpCredits("u1", 5, "topup-fail-refund");
|
||||
|
||||
const created = engine.processMention({
|
||||
mentionPostId: "m-fail-refund",
|
||||
callerUserId: "u1",
|
||||
parentPost: {
|
||||
id: "p-fail-refund",
|
||||
article: { id: "a-fail-refund", title: "T", body: "hello world" },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(engine.getWalletBalance("u1"), 4);
|
||||
engine.startJob(created.job.id);
|
||||
const failed = engine.failJob(created.job.id, { error: "tts_down", refund: true });
|
||||
assert.equal(failed.status, "failed_refunded");
|
||||
assert.equal(engine.getWalletBalance("u1"), 5);
|
||||
|
||||
const second = engine.failJob(created.job.id, { error: "tts_down", refund: true });
|
||||
assert.equal(second.status, "failed_refunded");
|
||||
assert.equal(engine.getWalletBalance("u1"), 5);
|
||||
});
|
||||
|
||||
test("round-trips state snapshot across engine restart", () => {
|
||||
const engine1 = createEngine();
|
||||
engine1.topUpCredits("u1", 5, "topup-snapshot");
|
||||
@@ -202,3 +251,51 @@ test("updateAsset patches stored asset metadata", () => {
|
||||
assert.equal(updated.storageKey, "audio/real-file.mp3");
|
||||
assert.equal(updated.sizeBytes, 12345);
|
||||
});
|
||||
|
||||
test("owner can takedown audio and hide it from access checks", () => {
|
||||
const engine = createEngine();
|
||||
engine.topUpCredits("owner", 5, "topup-takedown");
|
||||
const created = engine.processMention({
|
||||
mentionPostId: "m-takedown",
|
||||
callerUserId: "owner",
|
||||
parentPost: {
|
||||
id: "p-takedown",
|
||||
article: { id: "a-takedown", title: "T", body: "hello" },
|
||||
},
|
||||
});
|
||||
|
||||
engine.takedownAudio(created.job.assetId, "owner");
|
||||
assert.equal(engine.getAsset(created.job.assetId), null);
|
||||
assert.equal(engine.getAsset(created.job.assetId, { includeDeleted: true }).deletedAt !== null, true);
|
||||
assert.equal(engine.checkAudioAccess(created.job.assetId, "owner").reason, "not_found");
|
||||
});
|
||||
|
||||
test("retention prunes old article content and deletes stale assets", () => {
|
||||
const engine = createEngine();
|
||||
engine.topUpCredits("owner", 5, "topup-retention");
|
||||
const created = engine.processMention({
|
||||
mentionPostId: "m-retention",
|
||||
callerUserId: "owner",
|
||||
parentPost: {
|
||||
id: "p-retention",
|
||||
article: { id: "a-retention", title: "T", body: "hello retention" },
|
||||
},
|
||||
});
|
||||
|
||||
const job = engine.getJob(created.job.id);
|
||||
const asset = engine.getAsset(created.job.assetId, { includeDeleted: true });
|
||||
job.createdAt = "2020-01-01T00:00:00.000Z";
|
||||
asset.createdAt = "2020-01-01T00:00:00.000Z";
|
||||
|
||||
const summary = engine.applyRetention({
|
||||
rawArticleHours: 1,
|
||||
audioDays: 1,
|
||||
now: new Date("2020-01-03T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
assert.equal(summary.prunedArticleBodies, 1);
|
||||
assert.equal(summary.deletedAssets, 1);
|
||||
assert.equal(engine.getJob(created.job.id).article.content, "");
|
||||
assert.equal(typeof engine.getJob(created.job.id).article.contentHash, "string");
|
||||
assert.equal(engine.getAsset(created.job.assetId), null);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user