fix(news): preserve recoverable reads and verify publication against real services
This commit is contained in:
1 parent
8abfe352ef
commit
f7b9b55700
33 files changed
+1377
-220
No files matched your search
@@ -2,8 +2,22 @@
|
|||||||
|
|
||||||
Run `pnpm test:integration` on a Docker-capable host. Missing Docker or failed container setup fails the suite. CI runs this check before deployment.
|
Run `pnpm test:integration` on a Docker-capable host. Missing Docker or failed container setup fails the suite. CI runs this check before deployment.
|
||||||
|
|
||||||
Nine tests exercise real MariaDB catalog transactions and Redis caching: migration CLI replay/status, committed bulk edits and undo history, complete rollback when the second audit insert fails, competing previews, cache expiry metadata and cross-key invalidation isolation, concurrent request idempotency, operation/outbox rollback and exclusive delivery claims.
|
Fourteen tests exercise the production database commands, news actions, public article query and delivery worker against MariaDB 11.4.5 and Redis 7.4.2:
|
||||||
|
|
||||||
Each execution starts disposable MariaDB11.4.5 and Redis7.4.2 containers with random exposed ports and generated passwords. No production URLs, volumes or credentials are used. The real migration CLI is copied beneath a temporary isolated fixture root so its environment loader cannot read the checkout environment file. Cleanup attempts all connections and containers even if a previous cleanup fails.
|
- Migration CLI replay/status, committed catalog bulk edits and undo history, complete rollback after an audit insert fails, and competing catalog previews.
|
||||||
|
- Real Redis expiry metadata and cache-key isolation, concurrent request idempotency, operation/outbox rollback, and exclusive delivery claims.
|
||||||
|
- Concurrent duplicate draft creation and publication produce one article, one revision, one audit update and one effect per operation. The public query changes from cached absence to the full published content, including Unicode and a body larger than a TEXT column.
|
||||||
|
- A database trigger rejects the publication effect after the article, revision and audit writes. The transaction restores all preceding state; the identical request can then retry successfully without duplicate history.
|
||||||
|
- A trigger rejects the second scheduled-publication effect. Both article updates and both operations roll back, including the first queued effect.
|
||||||
|
- Competing scheduler ticks publish each due article once, preserve future articles and drafts, retain an unsigned bigint ID beyond JavaScript's safe integer range, and attribute a legacy authorless article to the system actor.
|
||||||
|
- A real Redis client disconnect leaves a publication committed and readable directly from MariaDB. The worker records a pending failed attempt. Reconnecting retains the stale cached absence until a successful outbox retry rotates the cache revision; the public query then returns the published article. The test advances only the queued retry timestamp to avoid sleeping.
|
||||||
|
|
||||||
The emulator fixture contains minimal catalog and audit tables. Migrations0027-0029 and0031 are exercised; this is not certification of every historical emulator schema or migration.
|
Each execution starts disposable containers with random exposed ports and generated passwords. No production URLs, volumes or credentials are used. The real migration CLI is copied beneath a temporary isolated fixture root so its environment loader cannot read the checkout environment file. Cleanup attempts all connections and containers even if a previous cleanup fails. The fixture inspection connection reads timestamps as UTC; the application keeps its production connection settings and host timezone. Each test receives a fresh news-cache revision, and the disconnect test reconnects its client in a `finally` block.
|
||||||
|
|
||||||
|
Only authorization/session lookup, translation lookup, Next.js revalidation/redirects, and the external publication webhook are mocked for the news actions. MariaDB, Drizzle, transactions, article revisions, history, operation deduplication, outbox claims/retries, Redis caching, the publication scheduler, public article lookup and the news delivery handler use their production implementations.
|
||||||
|
|
||||||
|
The fixture models the emulator's catalog/audit baseline plus the legacy article columns. Real migrations 0025-0029 and 0031 supply publication, editorial recovery, catalog packages, history and operations tables. This does not certify every historical emulator schema or migration.
|
||||||
|
|
||||||
|
These are application-service integration tests, not browser or HTTP end-to-end tests: they do not start a Next.js server, render the news page, verify login/ACL behavior, or send external webhooks. The cache outage test closes and restores the actual application Redis connection; it does not stop the Redis server or model a multi-host network partition. Passing TypeScript or unit tests without Docker is not a passing result for this suite.
|
||||||
|
|
||||||
|
Public comment pagination and reaction aggregation are covered by unit tests using the production Drizzle query builder with a substituted database transport, plus server-rendered page tests with substituted service results. This integration fixture does not create the users, article-comments or article-reactions tables and does not execute those two queries against MariaDB. The article-cache upgrade test verifies that legacy cached absence is ignored under the versioned key; it is a unit test, separate from the real Redis publication and delivery checks above.
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { once } from "node:events";
|
||||||
import { copyFile, mkdir, mkdtemp, rm } from "node:fs/promises";
|
import { copyFile, mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import type { RowDataPacket } from "mysql2/promise";
|
import type { RowDataPacket } from "mysql2/promise";
|
||||||
import mysql from "mysql2/promise";
|
import mysql from "mysql2/promise";
|
||||||
import {
|
import {
|
||||||
@@ -12,10 +13,39 @@ import {
|
|||||||
type StartedTestContainer,
|
type StartedTestContainer,
|
||||||
Wait,
|
Wait,
|
||||||
} from "testcontainers";
|
} from "testcontainers";
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
import {
|
||||||
|
afterAll,
|
||||||
|
beforeAll,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from "vitest";
|
||||||
|
import { WebsiteArticles } from "@/db/schema";
|
||||||
|
import { articleEditToken } from "@/lib/article-edit-token";
|
||||||
|
|
||||||
|
// Only request/framework boundaries are replaced; persistence, caches and workers are real.
|
||||||
|
const boundaries = vi.hoisted(() => ({ notify: vi.fn() }));
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: async () => ({ id: 7, username: "Integration editor" }),
|
||||||
|
}));
|
||||||
|
vi.mock("next-intl/server", () => ({
|
||||||
|
getTranslations: async () => (key: string) => key,
|
||||||
|
}));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: (url: string) => {
|
||||||
|
throw Error(`Unexpected integration redirect: ${url}`);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/webhook", () => ({ notify: boundaries.notify }));
|
||||||
|
|
||||||
const exec = promisify(execFile);
|
const exec = promisify(execFile);
|
||||||
|
const NEWS_REVISION_KEY = "cms:news:revision";
|
||||||
const migrations = [
|
const migrations = [
|
||||||
|
"0025_article_publication.sql",
|
||||||
|
"0026_article_editor_recovery.sql",
|
||||||
"0027_catalog_packages.sql",
|
"0027_catalog_packages.sql",
|
||||||
"0028_history_snapshots.sql",
|
"0028_history_snapshots.sql",
|
||||||
"0029_admin_table_views.sql",
|
"0029_admin_table_views.sql",
|
||||||
@@ -29,6 +59,10 @@ let appRedis: typeof import("@/lib/redis").redis;
|
|||||||
let commands: typeof import("@/features/catalog/server/bulk-offers");
|
let commands: typeof import("@/features/catalog/server/bulk-offers");
|
||||||
let operations: typeof import("@/features/operations/server");
|
let operations: typeof import("@/features/operations/server");
|
||||||
let cache: typeof import("@/lib/cache");
|
let cache: typeof import("@/lib/cache");
|
||||||
|
let articles: typeof import("@/actions/admin-articles");
|
||||||
|
let publicNews: typeof import("@/lib/services/news-detail");
|
||||||
|
let scheduler: typeof import("@/lib/services/news-scheduler");
|
||||||
|
let worker: typeof import("@/features/operations/worker");
|
||||||
let migrationRoot: string | undefined;
|
let migrationRoot: string | undefined;
|
||||||
let databaseUrl: string;
|
let databaseUrl: string;
|
||||||
|
|
||||||
@@ -85,7 +119,19 @@ beforeAll(async () => {
|
|||||||
.withStartupTimeout(120_000)
|
.withStartupTimeout(120_000)
|
||||||
.start();
|
.start();
|
||||||
databaseUrl = `mysql://integration:${databasePassword}@${maria.getHost()}:${maria.getMappedPort(3306)}/integration`;
|
databaseUrl = `mysql://integration:${databasePassword}@${maria.getHost()}:${maria.getMappedPort(3306)}/integration`;
|
||||||
connection = await mysql.createConnection(databaseUrl);
|
// Inspect stored TIMESTAMP values as UTC independently of the host timezone.
|
||||||
|
// The application pool retains its production configuration.
|
||||||
|
connection = await mysql.createConnection({
|
||||||
|
host: maria.getHost(),
|
||||||
|
port: maria.getMappedPort(3306),
|
||||||
|
user: "integration",
|
||||||
|
password: databasePassword,
|
||||||
|
database: "integration",
|
||||||
|
timezone: "Z",
|
||||||
|
supportBigNumbers: true,
|
||||||
|
bigNumberStrings: true,
|
||||||
|
charset: "utf8mb4",
|
||||||
|
});
|
||||||
redisContainer = await new GenericContainer("redis:7.4.2-alpine")
|
redisContainer = await new GenericContainer("redis:7.4.2-alpine")
|
||||||
.withCommand(["redis-server", "--requirepass", redisPassword])
|
.withCommand(["redis-server", "--requirepass", redisPassword])
|
||||||
.withExposedPorts(6379)
|
.withExposedPorts(6379)
|
||||||
@@ -107,6 +153,9 @@ beforeAll(async () => {
|
|||||||
await connection.query(
|
await connection.query(
|
||||||
"CREATE TABLE admin_audit_log (id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, action VARCHAR(191) NOT NULL DEFAULT '', target VARCHAR(191) NOT NULL DEFAULT '', target_id INT NULL, details TEXT NULL, `before` TEXT NULL, `after` TEXT NULL, diff TEXT NULL, ip_address VARCHAR(45) NULL, created_at VARCHAR(64) NOT NULL DEFAULT '', updated_at VARCHAR(64) NULL) ENGINE=InnoDB",
|
"CREATE TABLE admin_audit_log (id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, action VARCHAR(191) NOT NULL DEFAULT '', target VARCHAR(191) NOT NULL DEFAULT '', target_id INT NULL, details TEXT NULL, `before` TEXT NULL, `after` TEXT NULL, diff TEXT NULL, ip_address VARCHAR(45) NULL, created_at VARCHAR(64) NOT NULL DEFAULT '', updated_at VARCHAR(64) NULL) ENGINE=InnoDB",
|
||||||
);
|
);
|
||||||
|
await connection.query(
|
||||||
|
"CREATE TABLE website_articles (id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, slug VARCHAR(255) NOT NULL UNIQUE, title VARCHAR(255) NOT NULL, short_story VARCHAR(255) NOT NULL, full_story LONGTEXT NOT NULL, user_id INT NULL, image VARCHAR(255) NOT NULL, created_at TIMESTAMP NULL DEFAULT NULL, updated_at TIMESTAMP NULL DEFAULT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||||
|
);
|
||||||
// The emulator owns core tables. Exercise the real CMS migration CLI over this baseline.
|
// The emulator owns core tables. Exercise the real CMS migration CLI over this baseline.
|
||||||
migrationRoot = await mkdtemp(join(resolve("integration"), ".migration-"));
|
migrationRoot = await mkdtemp(join(resolve("integration"), ".migration-"));
|
||||||
await mkdir(join(migrationRoot, "scripts"));
|
await mkdir(join(migrationRoot, "scripts"));
|
||||||
@@ -135,6 +184,10 @@ beforeAll(async () => {
|
|||||||
commands = await import("@/features/catalog/server/bulk-offers");
|
commands = await import("@/features/catalog/server/bulk-offers");
|
||||||
cache = await import("@/lib/cache");
|
cache = await import("@/lib/cache");
|
||||||
operations = await import("@/features/operations/server");
|
operations = await import("@/features/operations/server");
|
||||||
|
articles = await import("@/actions/admin-articles");
|
||||||
|
publicNews = await import("@/lib/services/news-detail");
|
||||||
|
scheduler = await import("@/lib/services/news-scheduler");
|
||||||
|
worker = await import("@/features/operations/worker");
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -163,6 +216,15 @@ afterAll(async () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
if (!connection) throw Error("Integration database is not connected");
|
if (!connection) throw Error("Integration database is not connected");
|
||||||
|
vi.clearAllMocks();
|
||||||
|
await appRedis?.set(NEWS_REVISION_KEY, randomUUID());
|
||||||
|
await connection.query("DROP TRIGGER IF EXISTS reject_news_effect");
|
||||||
|
await connection.query(
|
||||||
|
"DROP TRIGGER IF EXISTS reject_second_scheduled_effect",
|
||||||
|
);
|
||||||
|
await connection.query("DELETE FROM website_article_revisions");
|
||||||
|
await connection.query("DELETE FROM website_article_drafts");
|
||||||
|
await connection.query("DELETE FROM website_articles");
|
||||||
await connection.query("DELETE FROM cms_outbox");
|
await connection.query("DELETE FROM cms_outbox");
|
||||||
await connection.query("DELETE FROM cms_operations");
|
await connection.query("DELETE FROM cms_operations");
|
||||||
await connection.query("DROP TRIGGER IF EXISTS reject_second_history");
|
await connection.query("DROP TRIGGER IF EXISTS reject_second_history");
|
||||||
@@ -201,7 +263,7 @@ describe("MariaDB migrations and catalog transactions", () => {
|
|||||||
{ name: "Saved" },
|
{ name: "Saved" },
|
||||||
]);
|
]);
|
||||||
expect((await migrate("--status")).stdout).toContain(
|
expect((await migrate("--status")).stdout).toContain(
|
||||||
"4/4 applied, 0 pending",
|
`${migrations.length}/${migrations.length} applied, 0 pending`,
|
||||||
);
|
);
|
||||||
const columns = await rows(
|
const columns = await rows(
|
||||||
"SELECT COLUMN_NAME, DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='admin_audit_log' AND COLUMN_NAME IN ('before','after') ORDER BY COLUMN_NAME",
|
"SELECT COLUMN_NAME, DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='admin_audit_log' AND COLUMN_NAME IN ('before','after') ORDER BY COLUMN_NAME",
|
||||||
@@ -419,3 +481,367 @@ describe("operation idempotency and transactional outbox", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function articleForm(extra: Record<string, string> = {}) {
|
||||||
|
const form = new FormData();
|
||||||
|
for (const [key, value] of Object.entries({
|
||||||
|
requestKey: randomUUID(),
|
||||||
|
title: "Integration news",
|
||||||
|
slug: "integration-news",
|
||||||
|
shortStory: "A real database publication",
|
||||||
|
fullStory: "<p>Original draft body</p>",
|
||||||
|
image: "/images/news.png",
|
||||||
|
status: "draft",
|
||||||
|
...extra,
|
||||||
|
}))
|
||||||
|
form.set(key, value);
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savedArticle(slug = "integration-news") {
|
||||||
|
if (!appDb) throw Error("Integration database is not connected");
|
||||||
|
const [article] = await appDb
|
||||||
|
.select()
|
||||||
|
.from(WebsiteArticles)
|
||||||
|
.where(eq(WebsiteArticles.slug, slug))
|
||||||
|
.limit(1);
|
||||||
|
if (!article) throw Error(`Missing article fixture: ${slug}`);
|
||||||
|
return article;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedScheduledArticles(now: Date) {
|
||||||
|
if (!appDb) throw Error("Integration database is not connected");
|
||||||
|
const before = new Date(now.getTime() - 60_000);
|
||||||
|
const after = new Date(now.getTime() + 60_000);
|
||||||
|
await appDb.insert(WebsiteArticles).values(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 1n,
|
||||||
|
slug: "due-earlier",
|
||||||
|
status: "scheduled",
|
||||||
|
publishAt: before,
|
||||||
|
userId: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9007199254740993n,
|
||||||
|
slug: "due-now",
|
||||||
|
status: "scheduled",
|
||||||
|
publishAt: now,
|
||||||
|
userId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3n,
|
||||||
|
slug: "future",
|
||||||
|
status: "scheduled",
|
||||||
|
publishAt: after,
|
||||||
|
userId: 7,
|
||||||
|
},
|
||||||
|
{ id: 4n, slug: "draft", status: "draft", publishAt: before, userId: 7 },
|
||||||
|
{
|
||||||
|
id: 5n,
|
||||||
|
slug: "already-public",
|
||||||
|
status: "published",
|
||||||
|
publishAt: before,
|
||||||
|
userId: 7,
|
||||||
|
},
|
||||||
|
].map((article) => ({
|
||||||
|
...article,
|
||||||
|
title: article.slug,
|
||||||
|
shortStory: "Scheduled integration fixture",
|
||||||
|
fullStory: `<p>${article.slug}</p>`,
|
||||||
|
image: "",
|
||||||
|
createdAt: before,
|
||||||
|
updatedAt: before,
|
||||||
|
publishedAt: article.status === "published" ? before : null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("real news publication, scheduling and cache delivery", () => {
|
||||||
|
it("creates one draft and publishes once under duplicate submits, preserving revision, audit and public content", async () => {
|
||||||
|
const draft = articleForm();
|
||||||
|
const created = await Promise.all([
|
||||||
|
articles.createArticle(draft),
|
||||||
|
articles.createArticle(draft),
|
||||||
|
]);
|
||||||
|
expect(created[0]).toMatchObject({ ok: true });
|
||||||
|
expect(created[1]).toEqual(created[0]);
|
||||||
|
expect(await rows("SELECT id FROM website_articles")).toHaveLength(1);
|
||||||
|
expect(await rows("SELECT kind FROM cms_operations")).toEqual([
|
||||||
|
{ kind: "news.create" },
|
||||||
|
]);
|
||||||
|
expect(await rows("SELECT id FROM website_article_revisions")).toHaveLength(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(boundaries.notify).not.toHaveBeenCalled();
|
||||||
|
const existing = await savedArticle();
|
||||||
|
expect(existing.status).toBe("draft");
|
||||||
|
expect(existing.publishedAt).toBeNull();
|
||||||
|
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
|
||||||
|
const negativeRevision = await appRedis?.get(NEWS_REVISION_KEY);
|
||||||
|
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
|
||||||
|
expect(await appRedis?.get(negativeKey)).toBe("null");
|
||||||
|
expect(await appRedis?.ttl(negativeKey)).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const body = `<p>${"Contenuto completo è 📰 ".repeat(4000)}</p>`;
|
||||||
|
const publish = articleForm({
|
||||||
|
id: String(existing.id),
|
||||||
|
baseToken: articleEditToken(existing),
|
||||||
|
status: "published",
|
||||||
|
fullStory: body,
|
||||||
|
});
|
||||||
|
const published = await Promise.all([
|
||||||
|
articles.updateArticle(publish),
|
||||||
|
articles.updateArticle(publish),
|
||||||
|
]);
|
||||||
|
expect(published[0]).toMatchObject({ ok: true });
|
||||||
|
expect(published[1]).toEqual(published[0]);
|
||||||
|
const saved = await savedArticle();
|
||||||
|
expect(saved).toMatchObject({
|
||||||
|
status: "published",
|
||||||
|
fullStory: body,
|
||||||
|
publishAt: null,
|
||||||
|
});
|
||||||
|
expect(saved.publishedAt).toBeInstanceOf(Date);
|
||||||
|
const revisions = await rows(
|
||||||
|
"SELECT payload FROM website_article_revisions",
|
||||||
|
);
|
||||||
|
expect(revisions).toHaveLength(1);
|
||||||
|
expect(JSON.parse(revisions[0].payload)).toMatchObject({
|
||||||
|
status: "draft",
|
||||||
|
fullStory: existing.fullStory,
|
||||||
|
});
|
||||||
|
const history = await rows(
|
||||||
|
"SELECT target,`before`,`after` FROM admin_audit_log",
|
||||||
|
);
|
||||||
|
expect(history).toHaveLength(1);
|
||||||
|
expect(history[0].target).toBe("news");
|
||||||
|
expect(JSON.parse(history[0].before)).toMatchObject({
|
||||||
|
status: "draft",
|
||||||
|
fullStory: existing.fullStory,
|
||||||
|
});
|
||||||
|
expect(JSON.parse(history[0].after)).toMatchObject({
|
||||||
|
status: "published",
|
||||||
|
fullStory: body,
|
||||||
|
});
|
||||||
|
expect(await rows("SELECT kind FROM cms_operations ORDER BY kind")).toEqual(
|
||||||
|
[{ kind: "news.create" }, { kind: "news.update" }],
|
||||||
|
);
|
||||||
|
expect(await rows("SELECT topic,status FROM cms_outbox")).toEqual([
|
||||||
|
{ topic: "news.refresh", status: "pending" },
|
||||||
|
{ topic: "news.refresh", status: "pending" },
|
||||||
|
]);
|
||||||
|
expect(boundaries.notify).toHaveBeenCalledOnce();
|
||||||
|
await worker.drainOperationEffects();
|
||||||
|
expect(await rows("SELECT status FROM cms_outbox")).toEqual([
|
||||||
|
{ status: "done" },
|
||||||
|
{ status: "done" },
|
||||||
|
]);
|
||||||
|
expect(await appRedis?.get(NEWS_REVISION_KEY)).not.toBe(negativeRevision);
|
||||||
|
expect(await publicNews.getPublishedArticle(existing.slug)).toMatchObject({
|
||||||
|
id: existing.id,
|
||||||
|
fullStory: body,
|
||||||
|
slug: existing.slug,
|
||||||
|
});
|
||||||
|
const publicKey = `news:${await appRedis?.get(NEWS_REVISION_KEY)}:article:v2:slug:${existing.slug}`;
|
||||||
|
expect(JSON.parse(String(await appRedis?.get(publicKey)))).toMatchObject({
|
||||||
|
id: String(existing.id),
|
||||||
|
fullStory: body,
|
||||||
|
});
|
||||||
|
cache.invalidateMemory(publicKey);
|
||||||
|
const fromRedis = await publicNews.getPublishedArticle(existing.slug);
|
||||||
|
expect(fromRedis?.id).toBe(existing.id);
|
||||||
|
expect(fromRedis?.publishedAt).toEqual(saved.publishedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back publication, revision, audit and operation when queuing its effect fails, then retries the same submit", async () => {
|
||||||
|
expect(await articles.createArticle(articleForm())).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
const existing = await savedArticle();
|
||||||
|
const publish = articleForm({
|
||||||
|
id: String(existing.id),
|
||||||
|
baseToken: articleEditToken(existing),
|
||||||
|
status: "published",
|
||||||
|
});
|
||||||
|
const operationsBefore = await rows("SELECT * FROM cms_operations");
|
||||||
|
const effectsBefore = await rows("SELECT * FROM cms_outbox");
|
||||||
|
await connection?.query(
|
||||||
|
"CREATE TRIGGER reject_news_effect BEFORE INSERT ON cms_outbox FOR EACH ROW BEGIN IF NEW.topic='news.refresh' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='forced news effect failure'; END IF; END",
|
||||||
|
);
|
||||||
|
expect(await articles.updateArticle(publish)).toMatchObject({ ok: false });
|
||||||
|
expect(await savedArticle()).toEqual(existing);
|
||||||
|
expect(await rows("SELECT id FROM website_article_revisions")).toHaveLength(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(await rows("SELECT id FROM admin_audit_log")).toHaveLength(0);
|
||||||
|
expect(await rows("SELECT * FROM cms_operations")).toEqual(
|
||||||
|
operationsBefore,
|
||||||
|
);
|
||||||
|
expect(await rows("SELECT * FROM cms_outbox")).toEqual(effectsBefore);
|
||||||
|
expect(boundaries.notify).not.toHaveBeenCalled();
|
||||||
|
await connection?.query("DROP TRIGGER reject_news_effect");
|
||||||
|
expect(await articles.updateArticle(publish)).toMatchObject({ ok: true });
|
||||||
|
expect(await articles.updateArticle(publish)).toMatchObject({ ok: true });
|
||||||
|
expect((await savedArticle()).status).toBe("published");
|
||||||
|
expect(await rows("SELECT id FROM website_article_revisions")).toHaveLength(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(await rows("SELECT id FROM admin_audit_log")).toHaveLength(1);
|
||||||
|
expect(await rows("SELECT id FROM cms_operations")).toHaveLength(2);
|
||||||
|
expect(await rows("SELECT id FROM cms_outbox")).toHaveLength(2);
|
||||||
|
expect(boundaries.notify).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back the entire scheduled batch if its second effect cannot be queued", async () => {
|
||||||
|
const now = new Date(Math.floor(Date.now() / 1000) * 1000);
|
||||||
|
await seedScheduledArticles(now);
|
||||||
|
const before = await rows("SELECT * FROM website_articles ORDER BY id");
|
||||||
|
await connection?.query(
|
||||||
|
"CREATE TRIGGER reject_second_scheduled_effect BEFORE INSERT ON cms_outbox FOR EACH ROW BEGIN IF NEW.topic='news.refresh' AND (SELECT COUNT(*) FROM cms_operations WHERE kind='news.schedule.publish')=2 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='forced second scheduled effect failure'; END IF; END",
|
||||||
|
);
|
||||||
|
await expect(scheduler.publishDueArticles(now)).rejects.toThrow();
|
||||||
|
expect(await rows("SELECT * FROM website_articles ORDER BY id")).toEqual(
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
expect(await rows("SELECT id FROM cms_operations")).toHaveLength(0);
|
||||||
|
expect(await rows("SELECT id FROM cms_outbox")).toHaveLength(0);
|
||||||
|
await connection?.query("DROP TRIGGER reject_second_scheduled_effect");
|
||||||
|
expect(await scheduler.publishDueArticles(now)).toBe(2);
|
||||||
|
expect(await rows("SELECT id FROM cms_operations")).toHaveLength(2);
|
||||||
|
expect(await rows("SELECT topic,status FROM cms_outbox")).toEqual([
|
||||||
|
{ topic: "news.refresh", status: "pending" },
|
||||||
|
{ topic: "news.refresh", status: "pending" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes competing scheduler ticks without duplicate effects or early publication", async () => {
|
||||||
|
const now = new Date(Math.floor(Date.now() / 1000) * 1000);
|
||||||
|
await seedScheduledArticles(now);
|
||||||
|
const untouched = await rows(
|
||||||
|
"SELECT * FROM website_articles WHERE id IN (3,4,5) ORDER BY id",
|
||||||
|
);
|
||||||
|
expect(await publicNews.getPublishedArticle("due-earlier")).toBeNull();
|
||||||
|
expect(await publicNews.getPublishedArticle("due-now")).toBeNull();
|
||||||
|
const results = await Promise.all([
|
||||||
|
scheduler.publishDueArticles(now),
|
||||||
|
scheduler.publishDueArticles(now),
|
||||||
|
]);
|
||||||
|
expect(results.sort()).toEqual([0, 2]);
|
||||||
|
expect(await scheduler.publishDueArticles(now)).toBe(0);
|
||||||
|
expect(
|
||||||
|
await rows(
|
||||||
|
"SELECT * FROM website_articles WHERE id IN (3,4,5) ORDER BY id",
|
||||||
|
),
|
||||||
|
).toEqual(untouched);
|
||||||
|
const published = await rows(
|
||||||
|
"SELECT CAST(id AS CHAR) AS id,status,published_at FROM website_articles WHERE id IN (1,9007199254740993) ORDER BY id",
|
||||||
|
);
|
||||||
|
expect(published).toEqual([
|
||||||
|
{ id: "1", status: "published", published_at: now },
|
||||||
|
{ id: "9007199254740993", status: "published", published_at: now },
|
||||||
|
]);
|
||||||
|
const recorded = await rows(
|
||||||
|
"SELECT actor_id,kind,result_json FROM cms_operations ORDER BY actor_id",
|
||||||
|
);
|
||||||
|
expect(recorded).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
recorded.map((entry) => ({
|
||||||
|
actor: entry.actor_id,
|
||||||
|
kind: entry.kind,
|
||||||
|
result: JSON.parse(entry.result_json),
|
||||||
|
})),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
actor: 0,
|
||||||
|
kind: "news.schedule.publish",
|
||||||
|
result: { articleId: "9007199254740993", published: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actor: 7,
|
||||||
|
kind: "news.schedule.publish",
|
||||||
|
result: { articleId: "1", published: true },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(await rows("SELECT topic,status FROM cms_outbox")).toEqual([
|
||||||
|
{ topic: "news.refresh", status: "pending" },
|
||||||
|
{ topic: "news.refresh", status: "pending" },
|
||||||
|
]);
|
||||||
|
await worker.drainOperationEffects();
|
||||||
|
expect(await publicNews.getPublishedArticle("due-earlier")).toMatchObject({
|
||||||
|
id: 1n,
|
||||||
|
});
|
||||||
|
expect(await publicNews.getPublishedArticle("due-now")).toMatchObject({
|
||||||
|
id: 9007199254740993n,
|
||||||
|
});
|
||||||
|
expect(await publicNews.getPublishedArticle("future")).toBeNull();
|
||||||
|
expect(await publicNews.getPublishedArticle("draft")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a committed publication readable during Redis disconnect and repairs cached absence through a retried delivery", async () => {
|
||||||
|
if (!appRedis) throw Error("Redis must be enabled in integration tests");
|
||||||
|
const redis = appRedis;
|
||||||
|
expect(await articles.createArticle(articleForm())).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
await worker.drainOperationEffects();
|
||||||
|
const existing = await savedArticle();
|
||||||
|
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
|
||||||
|
const negativeRevision = await redis.get(NEWS_REVISION_KEY);
|
||||||
|
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
|
||||||
|
expect(await redis.get(negativeKey)).toBe("null");
|
||||||
|
const publish = articleForm({
|
||||||
|
id: String(existing.id),
|
||||||
|
baseToken: articleEditToken(existing),
|
||||||
|
status: "published",
|
||||||
|
});
|
||||||
|
const disconnected = once(redis, "end");
|
||||||
|
redis.disconnect();
|
||||||
|
await disconnected;
|
||||||
|
try {
|
||||||
|
expect(redis.status).toBe("end");
|
||||||
|
expect(await articles.updateArticle(publish)).toMatchObject({ ok: true });
|
||||||
|
expect((await savedArticle()).status).toBe("published");
|
||||||
|
expect(await publicNews.getPublishedArticle(existing.slug)).toMatchObject(
|
||||||
|
{ id: existing.id },
|
||||||
|
);
|
||||||
|
await worker.drainOperationEffects();
|
||||||
|
const [pending] = await rows(
|
||||||
|
"SELECT status,attempts,last_error FROM cms_outbox WHERE status<>'done'",
|
||||||
|
);
|
||||||
|
expect(pending).toMatchObject({ status: "pending", attempts: 1 });
|
||||||
|
expect(pending.last_error).toBeTruthy();
|
||||||
|
await redis.connect();
|
||||||
|
expect(await redis.ping()).toBe("PONG");
|
||||||
|
expect(await redis.get(NEWS_REVISION_KEY)).toBe(negativeRevision);
|
||||||
|
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
|
||||||
|
// Make the real queued retry due without a wall-clock sleep.
|
||||||
|
await connection?.query(
|
||||||
|
"UPDATE cms_outbox SET available_at=UTC_TIMESTAMP(3) WHERE status='pending'",
|
||||||
|
);
|
||||||
|
await worker.drainOperationEffects();
|
||||||
|
expect(
|
||||||
|
await rows(
|
||||||
|
"SELECT status,attempts,last_error FROM cms_outbox ORDER BY attempts",
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{ status: "done", attempts: 1, last_error: null },
|
||||||
|
{ status: "done", attempts: 2, last_error: null },
|
||||||
|
]);
|
||||||
|
expect(await redis.get(NEWS_REVISION_KEY)).not.toBe(negativeRevision);
|
||||||
|
expect(await publicNews.getPublishedArticle(existing.slug)).toMatchObject(
|
||||||
|
{ id: existing.id, fullStory: existing.fullStory },
|
||||||
|
);
|
||||||
|
expect(await articles.updateArticle(publish)).toMatchObject({ ok: true });
|
||||||
|
expect(await rows("SELECT id FROM website_articles")).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
await rows("SELECT id FROM website_article_revisions"),
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(await rows("SELECT id FROM cms_operations")).toHaveLength(2);
|
||||||
|
expect(await rows("SELECT id FROM cms_outbox")).toHaveLength(2);
|
||||||
|
expect(boundaries.notify).toHaveBeenCalledOnce();
|
||||||
|
} finally {
|
||||||
|
if (redis.status === "end") await redis.connect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { beforeEach, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
missing: false,
|
||||||
|
fail: false,
|
||||||
|
sectionsFail: false,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: async () => ({ user: { id: "7" } }) }));
|
||||||
|
vi.mock("@/actions/article-comments", () => ({ postComment: async () => {} }));
|
||||||
|
vi.mock("@/actions/article-reactions", () => ({
|
||||||
|
toggleReaction: async () => {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||||
|
vi.mock("@/lib/public-page-performance", () => ({
|
||||||
|
withPublicPagePerformance: (_route: string, render: unknown) => render,
|
||||||
|
}));
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
notFound: () => {
|
||||||
|
throw Error("ARTICLE_NOT_FOUND");
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("next-intl/server", () => ({
|
||||||
|
getTranslations:
|
||||||
|
async () => (key: string, values?: Record<string, unknown>) =>
|
||||||
|
values ? `${key} ${Object.values(values).join(" ")}` : key,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/news-cache", () => ({
|
||||||
|
cacheNews: async (_key: string, _ttl: number, load: () => Promise<unknown>) =>
|
||||||
|
load(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { drizzle } = await import("drizzle-orm/mysql-proxy");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: drizzle(async () => {
|
||||||
|
throw Error("database offline");
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("@/lib/services/news-detail", () => ({
|
||||||
|
getPublishedArticle: async () => {
|
||||||
|
if (state.fail) throw Error("database offline private connection details");
|
||||||
|
if (state.missing) return null;
|
||||||
|
return {
|
||||||
|
id: 12n,
|
||||||
|
slug: "community-update",
|
||||||
|
title: "Community update",
|
||||||
|
shortStory: "Summary",
|
||||||
|
fullStory: "<p>Readable article body</p>",
|
||||||
|
image: "",
|
||||||
|
createdAt: new Date("2030-01-01T00:00:00Z"),
|
||||||
|
updatedAt: null,
|
||||||
|
publishAt: new Date("2030-02-01T00:00:00Z"),
|
||||||
|
publishedAt: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getArticleComments: async (_id: bigint, page: number | "last") => {
|
||||||
|
if (state.sectionsFail) throw Error("comments offline");
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 225n,
|
||||||
|
userId: 7,
|
||||||
|
comment: page === "last" ? "Newly posted comment" : "Earlier comment",
|
||||||
|
username: "Member",
|
||||||
|
createdAt: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 225,
|
||||||
|
page: page === "last" ? 9 : 1,
|
||||||
|
perPage: 25,
|
||||||
|
lastPage: 9,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getArticleReactions: async () => {
|
||||||
|
if (state.sectionsFail) throw Error("reactions offline");
|
||||||
|
return { counts: new Map([["love", 2]]), myReaction: "love" };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import ArticlePage, { generateMetadata } from "./page";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.missing = false;
|
||||||
|
state.fail = false;
|
||||||
|
state.sectionsFail = false;
|
||||||
|
});
|
||||||
|
const renderPage = async (search: Record<string, string> = {}) =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
await ArticlePage({
|
||||||
|
params: Promise.resolve({ slug: ["community-update"] }),
|
||||||
|
searchParams: Promise.resolve(search),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
it("keeps the article readable with explicit failures for comments and reactions", async () => {
|
||||||
|
state.sectionsFail = true;
|
||||||
|
const html = await renderPage();
|
||||||
|
expect(html).toContain("Readable article body");
|
||||||
|
expect(html).toContain("commentsUnavailable");
|
||||||
|
expect(html).toContain("reactionsUnavailable");
|
||||||
|
expect(html).not.toContain("noComments");
|
||||||
|
expect(html).not.toContain("noReactions");
|
||||||
|
expect(html).not.toContain("aria-pressed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows recoverable article failures without producing a false 404 or exposing diagnostics", async () => {
|
||||||
|
state.fail = true;
|
||||||
|
const html = await renderPage();
|
||||||
|
expect(html).toContain("loadError");
|
||||||
|
expect(html).toContain("retry");
|
||||||
|
expect(html).not.toContain("private connection details");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns not found only when a successful lookup found no public article", async () => {
|
||||||
|
state.missing = true;
|
||||||
|
await expect(renderPage()).rejects.toThrow("ARTICLE_NOT_FOUND");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the newest comment after posting and preserves existing reaction controls", async () => {
|
||||||
|
const html = await renderPage({ comment: "posted" });
|
||||||
|
expect(html).toContain("Newly posted comment");
|
||||||
|
expect(html).toContain("commentsPage=8#comments");
|
||||||
|
expect(html).toContain("success.posted");
|
||||||
|
expect(html).toContain('aria-pressed="true"');
|
||||||
|
expect(html).toContain("2030-02-01");
|
||||||
|
expect(html).not.toContain("2030-01-01");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads metadata from the same public article service", async () => {
|
||||||
|
expect(
|
||||||
|
await generateMetadata({
|
||||||
|
params: Promise.resolve({ slug: ["community-update"] }),
|
||||||
|
}),
|
||||||
|
).toMatchObject({ title: "Community update" });
|
||||||
|
});
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { and, asc, eq, inArray, or, sql } from "drizzle-orm";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
@@ -10,23 +9,22 @@ import { ContentCard, EmptyState } from "@/components/public/ui";
|
|||||||
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
||||||
import { articleSlug } from "@/lib/article-input";
|
import { articleSlug } from "@/lib/article-input";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import {
|
|
||||||
db,
|
|
||||||
User,
|
|
||||||
WebsiteArticleComments,
|
|
||||||
WebsiteArticleReactions,
|
|
||||||
WebsiteArticles,
|
|
||||||
} from "@/lib/db";
|
|
||||||
import { excerpt } from "@/lib/format";
|
import { excerpt } from "@/lib/format";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
import { withPublicPagePerformance } from "@/lib/public-page-performance";
|
import { withPublicPagePerformance } from "@/lib/public-page-performance";
|
||||||
import { sanitize } from "@/lib/sanitize";
|
import { sanitize } from "@/lib/sanitize";
|
||||||
import { cacheNews } from "@/lib/services/news-cache";
|
import {
|
||||||
|
getArticleComments,
|
||||||
|
getArticleReactions,
|
||||||
|
getPublishedArticle,
|
||||||
|
} from "@/lib/services/news-detail";
|
||||||
|
|
||||||
type SearchParams = Promise<{
|
type SearchParams = Promise<{
|
||||||
comment?: string;
|
comment?: string;
|
||||||
reaction?: string;
|
reaction?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
commentsPage?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||||
@@ -52,24 +50,10 @@ export async function generateMetadata({
|
|||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
|
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
|
||||||
const slug = articleSlug((await params).slug);
|
const slug = articleSlug((await params).slug);
|
||||||
const [article] = await db
|
const article = await getPublishedArticle(slug).catch(() => {
|
||||||
.select({
|
logger.error("Public article metadata failed", { module: "news" });
|
||||||
title: WebsiteArticles.title,
|
return null;
|
||||||
shortStory: WebsiteArticles.shortStory,
|
});
|
||||||
})
|
|
||||||
.from(WebsiteArticles)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(WebsiteArticles.slug, slug),
|
|
||||||
eq(WebsiteArticles.status, "published"),
|
|
||||||
or(
|
|
||||||
sql`${WebsiteArticles.publishAt} IS NULL`,
|
|
||||||
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
.catch(() => []);
|
|
||||||
if (!article) return { title: "Article" };
|
if (!article) return { title: "Article" };
|
||||||
return {
|
return {
|
||||||
title: article.title,
|
title: article.title,
|
||||||
@@ -101,36 +85,28 @@ async function ArticlePage({
|
|||||||
searchParams: SearchParams;
|
searchParams: SearchParams;
|
||||||
}) {
|
}) {
|
||||||
const slug = articleSlug((await params).slug);
|
const slug = articleSlug((await params).slug);
|
||||||
const { comment, reaction, error } = await searchParams;
|
const { comment, reaction, error, commentsPage } = await searchParams;
|
||||||
const t = await getTranslations("pages.article");
|
const t = await getTranslations("pages.article");
|
||||||
|
|
||||||
const article = await cacheNews(`article:slug:${slug}`, 60_000, async () => {
|
let article: Awaited<ReturnType<typeof getPublishedArticle>>;
|
||||||
const [row] = await db
|
try {
|
||||||
.select({
|
article = await getPublishedArticle(slug);
|
||||||
id: WebsiteArticles.id,
|
} catch {
|
||||||
slug: WebsiteArticles.slug,
|
logger.error("Public article lookup failed", { module: "news" });
|
||||||
title: WebsiteArticles.title,
|
return (
|
||||||
shortStory: WebsiteArticles.shortStory,
|
<main className="page-grid">
|
||||||
fullStory: WebsiteArticles.fullStory,
|
<ContentCard icon="📰" title={t("loadError")}>
|
||||||
image: WebsiteArticles.image,
|
<p role="alert">{t("loadError")}</p>
|
||||||
createdAt: WebsiteArticles.createdAt,
|
<a
|
||||||
updatedAt: WebsiteArticles.updatedAt,
|
className="btn btn-outline"
|
||||||
})
|
href={`/news/${encodeURIComponent(slug)}`}
|
||||||
.from(WebsiteArticles)
|
>
|
||||||
.where(
|
{t("retry")}
|
||||||
and(
|
</a>
|
||||||
eq(WebsiteArticles.slug, slug),
|
</ContentCard>
|
||||||
eq(WebsiteArticles.status, "published"),
|
</main>
|
||||||
or(
|
);
|
||||||
sql`${WebsiteArticles.publishAt} IS NULL`,
|
}
|
||||||
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
.catch(() => []);
|
|
||||||
return row ?? null;
|
|
||||||
});
|
|
||||||
if (!article) notFound();
|
if (!article) notFound();
|
||||||
|
|
||||||
const articleId = article.id;
|
const articleId = article.id;
|
||||||
@@ -139,60 +115,33 @@ async function ArticlePage({
|
|||||||
const loggedIn = Boolean(session?.user?.id);
|
const loggedIn = Boolean(session?.user?.id);
|
||||||
const sessionUserId = session?.user?.id ? Number(session.user.id) : null;
|
const sessionUserId = session?.user?.id ? Number(session.user.id) : null;
|
||||||
|
|
||||||
// Reaction counts grouped by reaction type for this article, plus the
|
const requestedPage =
|
||||||
// signed-in user's currently-active reaction (so its button reads as pressed).
|
comment === "posted" && !commentsPage ? "last" : Number(commentsPage ?? 1);
|
||||||
let reactionCounts = new Map<string, number>();
|
const [reactions, commentsResult] = await Promise.all([
|
||||||
let myReaction: string | null = null;
|
getArticleReactions(articleId, sessionUserId).catch(() => {
|
||||||
try {
|
logger.error("Public article reactions failed", {
|
||||||
const rows = await db
|
module: "news",
|
||||||
.select({
|
articleId: String(articleId),
|
||||||
reaction: WebsiteArticleReactions.reaction,
|
});
|
||||||
userId: WebsiteArticleReactions.userId,
|
return null;
|
||||||
})
|
}),
|
||||||
.from(WebsiteArticleReactions)
|
getArticleComments(articleId, requestedPage).catch(() => {
|
||||||
.where(
|
logger.error("Public article comments failed", {
|
||||||
and(
|
module: "news",
|
||||||
eq(WebsiteArticleReactions.articleId, articleId),
|
articleId: String(articleId),
|
||||||
eq(WebsiteArticleReactions.active, true),
|
});
|
||||||
),
|
return null;
|
||||||
);
|
}),
|
||||||
for (const r of rows) {
|
]);
|
||||||
reactionCounts.set(r.reaction, (reactionCounts.get(r.reaction) ?? 0) + 1);
|
const reactionCounts = reactions?.counts ?? new Map<string, number>();
|
||||||
if (sessionUserId !== null && r.userId === sessionUserId)
|
const myReaction = reactions?.myReaction ?? null;
|
||||||
myReaction = r.reaction;
|
const comments = commentsResult?.rows ?? [];
|
||||||
}
|
const articleHref = `/news/${encodeURIComponent(slug)}`;
|
||||||
} catch {
|
const commentsHref = (page: number) =>
|
||||||
reactionCounts = new Map();
|
`${articleHref}?commentsPage=${page}#comments`;
|
||||||
myReaction = null;
|
const retryHref = commentsPage
|
||||||
}
|
? `${articleHref}?${new URLSearchParams({ commentsPage })}`
|
||||||
|
: articleHref;
|
||||||
// Comments for this article (oldest first).
|
|
||||||
let comments: (typeof WebsiteArticleComments.$inferSelect)[] = [];
|
|
||||||
try {
|
|
||||||
comments = await db
|
|
||||||
.select()
|
|
||||||
.from(WebsiteArticleComments)
|
|
||||||
.where(eq(WebsiteArticleComments.articleId, articleId))
|
|
||||||
.orderBy(asc(WebsiteArticleComments.createdAt))
|
|
||||||
.limit(200);
|
|
||||||
} catch {
|
|
||||||
comments = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve usernames for the commenters (userId is the emulator users.id, Int).
|
|
||||||
const commenterIds = Array.from(new Set(comments.map((c) => c.userId)));
|
|
||||||
let usernameById = new Map<number, string>();
|
|
||||||
if (commenterIds.length > 0) {
|
|
||||||
try {
|
|
||||||
const users = await db
|
|
||||||
.select({ id: User.id, username: User.username })
|
|
||||||
.from(User)
|
|
||||||
.where(inArray(User.id, commenterIds));
|
|
||||||
usernameById = new Map(users.map((u) => [u.id, u.username]));
|
|
||||||
} catch {
|
|
||||||
usernameById = new Map();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error === "empty"
|
error === "empty"
|
||||||
@@ -231,7 +180,9 @@ async function ArticlePage({
|
|||||||
icon="📰"
|
icon="📰"
|
||||||
title={article.title}
|
title={article.title}
|
||||||
subtitle={
|
subtitle={
|
||||||
article.createdAt ? formatDate(article.createdAt, "date") : undefined
|
(article.publishAt ?? article.createdAt)
|
||||||
|
? formatDate(article.publishAt ?? article.createdAt, "date")
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
<Link className="btn btn-outline" href="/news">
|
<Link className="btn btn-outline" href="/news">
|
||||||
@@ -261,7 +212,14 @@ async function ArticlePage({
|
|||||||
title={t("reactionsTitle")}
|
title={t("reactionsTitle")}
|
||||||
subtitle={t("reactionsSubtitle")}
|
subtitle={t("reactionsSubtitle")}
|
||||||
>
|
>
|
||||||
{loggedIn ? (
|
{!reactions ? (
|
||||||
|
<div role="alert">
|
||||||
|
<p>{t("reactionsUnavailable")}</p>
|
||||||
|
<a className="btn btn-outline" href={retryHref}>
|
||||||
|
{t("retry")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : loggedIn ? (
|
||||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||||
{REACTIONS.map((r) => {
|
{REACTIONS.map((r) => {
|
||||||
const active = myReaction === r.key;
|
const active = myReaction === r.key;
|
||||||
@@ -331,80 +289,131 @@ async function ArticlePage({
|
|||||||
</ContentCard>
|
</ContentCard>
|
||||||
|
|
||||||
{/* ── Comments ──────────────────────────────────────────── */}
|
{/* ── Comments ──────────────────────────────────────────── */}
|
||||||
<ContentCard
|
<section id="comments" aria-label={t("commentsTitle")}>
|
||||||
icon="🗨️"
|
<ContentCard
|
||||||
title={t("commentsTitle")}
|
icon="🗨️"
|
||||||
subtitle={
|
title={t("commentsTitle")}
|
||||||
comments.length > 0
|
subtitle={
|
||||||
? t("commentsCount", { count: comments.length })
|
commentsResult && commentsResult.total > 0
|
||||||
: t("commentsSubtitle")
|
? t("commentsCount", { count: commentsResult.total })
|
||||||
}
|
: t("commentsSubtitle")
|
||||||
>
|
}
|
||||||
{comments.length === 0 ? (
|
>
|
||||||
<EmptyState icon="🗨️">{t("noComments")}</EmptyState>
|
{!commentsResult ? (
|
||||||
) : (
|
<div role="alert">
|
||||||
<div className="grid" style={{ marginBottom: "1.5rem" }}>
|
<p>{t("commentsUnavailable")}</p>
|
||||||
{comments.map((c) => {
|
<a className="btn btn-outline" href={`${retryHref}#comments`}>
|
||||||
const name = usernameById.get(c.userId);
|
{t("retry")}
|
||||||
return (
|
</a>
|
||||||
<article key={String(c.id)} className="card">
|
|
||||||
<p
|
|
||||||
className="muted"
|
|
||||||
style={{ marginTop: 0, marginBottom: "0.35rem" }}
|
|
||||||
>
|
|
||||||
{name ? (
|
|
||||||
<Link href={`/u/${name}`}>{name}</Link>
|
|
||||||
) : (
|
|
||||||
<span>{t("userNumber", { id: c.userId })}</span>
|
|
||||||
)}
|
|
||||||
{c.createdAt ? (
|
|
||||||
<span> · {formatDate(c.createdAt, "date", "")}</span>
|
|
||||||
) : null}
|
|
||||||
</p>
|
|
||||||
<p style={{ margin: 0, whiteSpace: "pre-wrap" }}>
|
|
||||||
{c.comment}
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loggedIn ? (
|
|
||||||
<form action={postComment} className="card">
|
|
||||||
<input type="hidden" name="articleId" value={String(articleId)} />
|
|
||||||
<input type="hidden" name="slug" value={slug} />
|
|
||||||
<label
|
|
||||||
htmlFor="comment"
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
fontWeight: 700,
|
|
||||||
marginBottom: "0.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("addComment")}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="comment"
|
|
||||||
name="comment"
|
|
||||||
required
|
|
||||||
maxLength={255}
|
|
||||||
rows={3}
|
|
||||||
placeholder={t("commentPlaceholder")}
|
|
||||||
style={{ width: "100%", resize: "vertical" }}
|
|
||||||
/>
|
|
||||||
<div style={{ marginTop: "0.75rem" }}>
|
|
||||||
<button type="submit" className="btn btn-primary">
|
|
||||||
{t("postComment")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
) : comments.length === 0 ? (
|
||||||
) : (
|
<EmptyState icon="🗨️">{t("noComments")}</EmptyState>
|
||||||
<p className="muted">
|
) : (
|
||||||
<Link href="/login">{t("logIn")}</Link> {t("toComment")}
|
<div className="grid" style={{ marginBottom: "1.5rem" }}>
|
||||||
</p>
|
{comments.map((c) => {
|
||||||
)}
|
const name = c.username;
|
||||||
</ContentCard>
|
return (
|
||||||
|
<article key={String(c.id)} className="card">
|
||||||
|
<p
|
||||||
|
className="muted"
|
||||||
|
style={{ marginTop: 0, marginBottom: "0.35rem" }}
|
||||||
|
>
|
||||||
|
{name ? (
|
||||||
|
<Link href={`/u/${name}`}>{name}</Link>
|
||||||
|
) : (
|
||||||
|
<span>{t("userNumber", { id: c.userId })}</span>
|
||||||
|
)}
|
||||||
|
{c.createdAt ? (
|
||||||
|
<span> · {formatDate(c.createdAt, "date", "")}</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
overflowWrap: "anywhere",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.comment}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{commentsResult && commentsResult.lastPage > 1 ? (
|
||||||
|
<nav
|
||||||
|
aria-label={t("commentsPagination")}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3"
|
||||||
|
style={{ marginBottom: "1rem" }}
|
||||||
|
>
|
||||||
|
{commentsResult.page > 1 ? (
|
||||||
|
<Link
|
||||||
|
className="btn btn-outline"
|
||||||
|
href={commentsHref(commentsResult.page - 1)}
|
||||||
|
>
|
||||||
|
{t("previous")}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{t("commentsPageSummary", {
|
||||||
|
page: commentsResult.page,
|
||||||
|
pages: commentsResult.lastPage,
|
||||||
|
total: commentsResult.total,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{commentsResult.page < commentsResult.lastPage ? (
|
||||||
|
<Link
|
||||||
|
className="btn btn-outline"
|
||||||
|
href={commentsHref(commentsResult.page + 1)}
|
||||||
|
>
|
||||||
|
{t("next")}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loggedIn ? (
|
||||||
|
<form action={postComment} className="card">
|
||||||
|
<input type="hidden" name="articleId" value={String(articleId)} />
|
||||||
|
<input type="hidden" name="slug" value={slug} />
|
||||||
|
<label
|
||||||
|
htmlFor="comment"
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontWeight: 700,
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("addComment")}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="comment"
|
||||||
|
name="comment"
|
||||||
|
required
|
||||||
|
maxLength={255}
|
||||||
|
rows={3}
|
||||||
|
placeholder={t("commentPlaceholder")}
|
||||||
|
style={{ width: "100%", resize: "vertical" }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: "0.75rem" }}>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
{t("postComment")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<p className="muted">
|
||||||
|
<Link href="/login">{t("logIn")}</Link> {t("toComment")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</ContentCard>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
fail: false,
|
||||||
|
articleRows: [] as unknown[][],
|
||||||
|
commentRows: [] as unknown[][],
|
||||||
|
reactionRows: [] as unknown[][],
|
||||||
|
total: 225,
|
||||||
|
queries: [] as { sql: string; params: unknown[] }[],
|
||||||
|
values: new Map<string, string>(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/news-cache", () => ({
|
||||||
|
cacheNews: async (
|
||||||
|
key: string,
|
||||||
|
_ttl: number,
|
||||||
|
query: () => Promise<unknown>,
|
||||||
|
) => {
|
||||||
|
const existing = state.values.get(key);
|
||||||
|
if (existing !== undefined) return JSON.parse(existing);
|
||||||
|
const result = await query();
|
||||||
|
const serialized = JSON.stringify(result);
|
||||||
|
state.values.set(key, serialized);
|
||||||
|
return JSON.parse(serialized);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { drizzle } = await import("drizzle-orm/mysql-proxy");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: drizzle(async (sql, params) => {
|
||||||
|
state.queries.push({ sql, params });
|
||||||
|
if (state.fail) throw new Error("database unavailable");
|
||||||
|
if (sql.includes("count(*)") && !sql.includes("group by"))
|
||||||
|
return { rows: [[state.total]] };
|
||||||
|
if (sql.includes("website_article_comments"))
|
||||||
|
return { rows: state.commentRows };
|
||||||
|
if (sql.includes("website_article_reactions"))
|
||||||
|
return { rows: state.reactionRows };
|
||||||
|
return { rows: state.articleRows };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import {
|
||||||
|
getArticleComments,
|
||||||
|
getArticleReactions,
|
||||||
|
getPublishedArticle,
|
||||||
|
} from "./news-detail";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.fail = false;
|
||||||
|
state.values.clear();
|
||||||
|
state.queries = [];
|
||||||
|
state.articleRows = [];
|
||||||
|
state.commentRows = [];
|
||||||
|
state.reactionRows = [];
|
||||||
|
state.total = 225;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("public article loading", () => {
|
||||||
|
it("keeps database failures retryable instead of caching a missing article", async () => {
|
||||||
|
state.fail = true;
|
||||||
|
await expect(getPublishedArticle("community-update")).rejects.toThrow();
|
||||||
|
state.fail = false;
|
||||||
|
expect(await getPublishedArticle("community-update")).toBeNull();
|
||||||
|
expect(state.queries).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips large IDs and publication dates through a JSON cache", async () => {
|
||||||
|
state.articleRows = [
|
||||||
|
[
|
||||||
|
"9007199254740993",
|
||||||
|
"community-update",
|
||||||
|
"Title",
|
||||||
|
"Summary",
|
||||||
|
"<p>Body</p>",
|
||||||
|
"",
|
||||||
|
"2030-01-01 10:00:00",
|
||||||
|
"2030-02-01 10:00:00",
|
||||||
|
"2030-02-01 10:00:00",
|
||||||
|
"2030-02-01 10:01:00",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
const article = await getPublishedArticle("community-update");
|
||||||
|
expect(article?.id).toBe(9007199254740993n);
|
||||||
|
expect(article?.publishAt).toEqual(new Date("2030-02-01T10:00:00Z"));
|
||||||
|
expect(article?.createdAt).toEqual(new Date("2030-01-01T10:00:00Z"));
|
||||||
|
expect(await getPublishedArticle("community-update")).toEqual(article);
|
||||||
|
expect(state.queries).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only selects articles that are published and already due", async () => {
|
||||||
|
expect(await getPublishedArticle("quote'")).toBeNull();
|
||||||
|
expect(state.queries[0].sql).toContain("`status` = ?");
|
||||||
|
expect(state.queries[0].sql).toContain("<= NOW()");
|
||||||
|
expect(state.queries[0].params).toEqual(["quote'", "published", 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("public article comments", () => {
|
||||||
|
it("makes comments beyond the former 200 item ceiling reachable", async () => {
|
||||||
|
state.commentRows = [
|
||||||
|
["225", 7, "Comment beyond 200", "2030-01-01 12:00:00", "Member"],
|
||||||
|
];
|
||||||
|
const result = await getArticleComments(12n, 9);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
total: 225,
|
||||||
|
page: 9,
|
||||||
|
perPage: 25,
|
||||||
|
lastPage: 9,
|
||||||
|
rows: [{ id: 225n, comment: "Comment beyond 200", username: "Member" }],
|
||||||
|
});
|
||||||
|
expect(state.queries[1].params.slice(-2)).toEqual([25, 200]);
|
||||||
|
expect(state.queries[1].sql).toMatch(
|
||||||
|
/order by .*`created_at` asc, .*`id` asc/,
|
||||||
|
);
|
||||||
|
expect(state.queries.every((query) => query.params[0] === 12n)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[-5, 1, 0],
|
||||||
|
[NaN, 1, 0],
|
||||||
|
[Infinity, 1, 0],
|
||||||
|
[1e99, 9, 200],
|
||||||
|
["last", 9, 200],
|
||||||
|
])("bounds requested page %s", async (requested, page, offset) => {
|
||||||
|
expect(
|
||||||
|
await getArticleComments(12n, requested as number | "last"),
|
||||||
|
).toMatchObject({ page });
|
||||||
|
expect(state.queries[1].params).toEqual(
|
||||||
|
offset === 0 ? [12n, 25] : [12n, 25, offset],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a successful empty page", async () => {
|
||||||
|
state.total = 0;
|
||||||
|
expect(await getArticleComments(12n, "last")).toMatchObject({
|
||||||
|
rows: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
lastPage: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a failed comment read instead of claiming there are no comments", async () => {
|
||||||
|
state.fail = true;
|
||||||
|
await expect(getArticleComments(12n)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("public article reactions", () => {
|
||||||
|
it("aggregates counts and the current user's reaction without loading every vote", async () => {
|
||||||
|
state.reactionRows = [
|
||||||
|
["love", 1200, 1],
|
||||||
|
["like", 30, 0],
|
||||||
|
];
|
||||||
|
const result = await getArticleReactions(12n, 7);
|
||||||
|
expect(result.counts.get("love")).toBe(1200);
|
||||||
|
expect(result.myReaction).toBe("love");
|
||||||
|
expect(state.queries[0].sql).toContain("group by");
|
||||||
|
expect(state.queries[0].params).toContain(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not hide a failed reaction read behind zero counts", async () => {
|
||||||
|
state.fail = true;
|
||||||
|
await expect(getArticleReactions(12n, null)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores legacy cached absence and loads the current public article", async () => {
|
||||||
|
state.values.set("article:slug:community-update", "null");
|
||||||
|
state.articleRows = [
|
||||||
|
[
|
||||||
|
"12",
|
||||||
|
"community-update",
|
||||||
|
"Current article",
|
||||||
|
"Summary",
|
||||||
|
"<p>Current body</p>",
|
||||||
|
"",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
expect(await getPublishedArticle("community-update")).toMatchObject({
|
||||||
|
id: 12n,
|
||||||
|
title: "Current article",
|
||||||
|
fullStory: "<p>Current body</p>",
|
||||||
|
});
|
||||||
|
state.fail = true;
|
||||||
|
expect(await getPublishedArticle("community-update")).toMatchObject({
|
||||||
|
id: 12n,
|
||||||
|
title: "Current article",
|
||||||
|
});
|
||||||
|
expect(state.queries).toHaveLength(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { and, asc, count, eq, or, sql } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
User,
|
||||||
|
WebsiteArticleComments,
|
||||||
|
WebsiteArticleReactions,
|
||||||
|
WebsiteArticles,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { cacheNews } from "@/lib/services/news-cache";
|
||||||
|
|
||||||
|
/** Null means a successful lookup found no public article. Failed reads must reject. */
|
||||||
|
export async function getPublishedArticle(slug: string) {
|
||||||
|
const cached = await cacheNews(
|
||||||
|
`article:v2:slug:${slug}`,
|
||||||
|
60_000,
|
||||||
|
async () => {
|
||||||
|
const [article] = await db
|
||||||
|
.select({
|
||||||
|
id: WebsiteArticles.id,
|
||||||
|
slug: WebsiteArticles.slug,
|
||||||
|
title: WebsiteArticles.title,
|
||||||
|
shortStory: WebsiteArticles.shortStory,
|
||||||
|
fullStory: WebsiteArticles.fullStory,
|
||||||
|
image: WebsiteArticles.image,
|
||||||
|
createdAt: WebsiteArticles.createdAt,
|
||||||
|
updatedAt: WebsiteArticles.updatedAt,
|
||||||
|
publishAt: WebsiteArticles.publishAt,
|
||||||
|
publishedAt: WebsiteArticles.publishedAt,
|
||||||
|
})
|
||||||
|
.from(WebsiteArticles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(WebsiteArticles.slug, slug),
|
||||||
|
eq(WebsiteArticles.status, "published"),
|
||||||
|
or(
|
||||||
|
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||||
|
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!article) return null;
|
||||||
|
// Redis uses JSON: preserve full bigint precision and restore dates on reads.
|
||||||
|
return {
|
||||||
|
...article,
|
||||||
|
id: String(article.id),
|
||||||
|
createdAt: article.createdAt?.toISOString() ?? null,
|
||||||
|
updatedAt: article.updatedAt?.toISOString() ?? null,
|
||||||
|
publishAt: article.publishAt?.toISOString() ?? null,
|
||||||
|
publishedAt: article.publishedAt?.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!cached) return null;
|
||||||
|
return {
|
||||||
|
...cached,
|
||||||
|
id: BigInt(cached.id),
|
||||||
|
createdAt: cached.createdAt ? new Date(cached.createdAt) : null,
|
||||||
|
updatedAt: cached.updatedAt ? new Date(cached.updatedAt) : null,
|
||||||
|
publishAt: cached.publishAt ? new Date(cached.publishAt) : null,
|
||||||
|
publishedAt: cached.publishedAt ? new Date(cached.publishedAt) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublishedArticle = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof getPublishedArticle>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export async function getArticleComments(
|
||||||
|
articleId: bigint,
|
||||||
|
requestedPage: number | "last" = 1,
|
||||||
|
) {
|
||||||
|
const where = eq(WebsiteArticleComments.articleId, articleId);
|
||||||
|
const [totals] = await db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(WebsiteArticleComments)
|
||||||
|
.where(where);
|
||||||
|
const total = Number(totals?.value ?? 0);
|
||||||
|
const perPage = 25;
|
||||||
|
const lastPage = Math.max(1, Math.ceil(total / perPage));
|
||||||
|
const page =
|
||||||
|
requestedPage === "last"
|
||||||
|
? lastPage
|
||||||
|
: Math.min(
|
||||||
|
lastPage,
|
||||||
|
Number.isFinite(requestedPage)
|
||||||
|
? Math.max(1, Math.trunc(requestedPage))
|
||||||
|
: 1,
|
||||||
|
);
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: WebsiteArticleComments.id,
|
||||||
|
userId: WebsiteArticleComments.userId,
|
||||||
|
comment: WebsiteArticleComments.comment,
|
||||||
|
createdAt: WebsiteArticleComments.createdAt,
|
||||||
|
username: User.username,
|
||||||
|
})
|
||||||
|
.from(WebsiteArticleComments)
|
||||||
|
.leftJoin(User, eq(User.id, WebsiteArticleComments.userId))
|
||||||
|
.where(where)
|
||||||
|
.orderBy(
|
||||||
|
asc(WebsiteArticleComments.createdAt),
|
||||||
|
asc(WebsiteArticleComments.id),
|
||||||
|
)
|
||||||
|
.limit(perPage)
|
||||||
|
.offset((page - 1) * perPage);
|
||||||
|
return { rows, total, page, perPage, lastPage };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArticleComments = Awaited<ReturnType<typeof getArticleComments>>;
|
||||||
|
|
||||||
|
export async function getArticleReactions(
|
||||||
|
articleId: bigint,
|
||||||
|
userId: number | null,
|
||||||
|
) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
reaction: WebsiteArticleReactions.reaction,
|
||||||
|
count: count(),
|
||||||
|
mine:
|
||||||
|
userId === null
|
||||||
|
? sql<number>`0`
|
||||||
|
: sql<number>`MAX(CASE WHEN ${WebsiteArticleReactions.userId} = ${userId} THEN 1 ELSE 0 END)`,
|
||||||
|
})
|
||||||
|
.from(WebsiteArticleReactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(WebsiteArticleReactions.articleId, articleId),
|
||||||
|
eq(WebsiteArticleReactions.active, true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(WebsiteArticleReactions.reaction);
|
||||||
|
return {
|
||||||
|
counts: new Map(rows.map((row) => [row.reaction, Number(row.count)])),
|
||||||
|
myReaction: rows.find((row) => Number(row.mine) > 0)?.reaction ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArticleReactions = Awaited<ReturnType<typeof getArticleReactions>>;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { SQL } from "drizzle-orm";
|
import type { SQL } from "drizzle-orm";
|
||||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||||
|
import { format } from "mysql2";
|
||||||
import { beforeEach, expect, it, vi } from "vitest";
|
import { beforeEach, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const state = vi.hoisted(() => ({
|
const state = vi.hoisted(() => ({
|
||||||
@@ -9,6 +10,7 @@ const state = vi.hoisted(() => ({
|
|||||||
status: string;
|
status: string;
|
||||||
publishAt: Date;
|
publishAt: Date;
|
||||||
}>,
|
}>,
|
||||||
|
queries: [] as Array<{ sql: string; params: unknown[] }>,
|
||||||
operations: [] as unknown[][],
|
operations: [] as unknown[][],
|
||||||
effects: [] as unknown[][],
|
effects: [] as unknown[][],
|
||||||
failOutbox: false,
|
failOutbox: false,
|
||||||
@@ -19,15 +21,20 @@ vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
|||||||
vi.mock("./news-cache", () => ({ invalidateNewsCache: state.refresh }));
|
vi.mock("./news-cache", () => ({ invalidateNewsCache: state.refresh }));
|
||||||
vi.mock("@/lib/db", () => {
|
vi.mock("@/lib/db", () => {
|
||||||
const dialect = new MySqlDialect();
|
const dialect = new MySqlDialect();
|
||||||
|
const timestamp = (value: unknown) =>
|
||||||
|
value instanceof Date
|
||||||
|
? value
|
||||||
|
: new Date(`${String(value).replace(" ", "T")}Z`);
|
||||||
const tx = {
|
const tx = {
|
||||||
execute: async (query: SQL) => {
|
execute: async (query: SQL) => {
|
||||||
const { sql, params } = dialect.sqlToQuery(query);
|
const { sql, params } = dialect.sqlToQuery(query);
|
||||||
|
state.queries.push({ sql, params });
|
||||||
if (sql.startsWith("SELECT"))
|
if (sql.startsWith("SELECT"))
|
||||||
return [
|
return [
|
||||||
state.articles
|
state.articles
|
||||||
.filter(
|
.filter(
|
||||||
(a) =>
|
(a) =>
|
||||||
a.status === "scheduled" && a.publishAt <= (params[0] as Date),
|
a.status === "scheduled" && a.publishAt <= timestamp(params[0]),
|
||||||
)
|
)
|
||||||
.slice(0, 100),
|
.slice(0, 100),
|
||||||
];
|
];
|
||||||
@@ -37,7 +44,7 @@ vi.mock("@/lib/db", () => {
|
|||||||
state.loseRace ||
|
state.loseRace ||
|
||||||
!article ||
|
!article ||
|
||||||
article.status !== "scheduled" ||
|
article.status !== "scheduled" ||
|
||||||
article.publishAt > (params[3] as Date)
|
article.publishAt > timestamp(params[3])
|
||||||
)
|
)
|
||||||
return [{ affectedRows: 0 }];
|
return [{ affectedRows: 0 }];
|
||||||
article.status = "published";
|
article.status = "published";
|
||||||
@@ -83,6 +90,7 @@ beforeEach(() => {
|
|||||||
publishAt: new Date("2030-01-01T00:00:00Z"),
|
publishAt: new Date("2030-01-01T00:00:00Z"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
state.queries = [];
|
||||||
state.operations = [];
|
state.operations = [];
|
||||||
state.effects = [];
|
state.effects = [];
|
||||||
state.failOutbox = false;
|
state.failOutbox = false;
|
||||||
@@ -128,3 +136,25 @@ it("reports committed publication even when immediate cache refresh fails", asyn
|
|||||||
expect(state.effects).toHaveLength(1);
|
expect(state.effects).toHaveLength(1);
|
||||||
expect(state.refresh).toHaveBeenCalledOnce();
|
expect(state.refresh).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses the same UTC cutoff and publication timestamps regardless of the MySQL client timezone", async () => {
|
||||||
|
const instant = new Date("2030-06-01T08:09:10.123Z");
|
||||||
|
expect(await publishDueArticles(instant)).toBe(1);
|
||||||
|
const statements = state.queries.filter(
|
||||||
|
({ sql }) =>
|
||||||
|
sql.startsWith("SELECT") || sql.startsWith("UPDATE website_articles"),
|
||||||
|
);
|
||||||
|
expect(statements).toHaveLength(2);
|
||||||
|
for (const statement of statements) {
|
||||||
|
expect(format(statement.sql, statement.params, false, "+02:00")).toBe(
|
||||||
|
format(statement.sql, statement.params, false, "-07:00"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(statements[0].params).toEqual(["2030-06-01 08:09:10.123"]);
|
||||||
|
expect(statements[1].params).toEqual([
|
||||||
|
"2030-06-01 08:09:10.123",
|
||||||
|
"2030-06-01 08:09:10.123",
|
||||||
|
"1",
|
||||||
|
"2030-06-01 08:09:10.123",
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -9,9 +9,11 @@ import { invalidateNewsCache } from "./news-cache";
|
|||||||
|
|
||||||
/** Publish a bounded batch and persist its refresh work before committing. */
|
/** Publish a bounded batch and persist its refresh work before committing. */
|
||||||
export async function publishDueArticles(now = new Date()): Promise<number> {
|
export async function publishDueArticles(now = new Date()): Promise<number> {
|
||||||
|
// Match Drizzle's TIMESTAMP writes; raw Date parameters use the host timezone.
|
||||||
|
const utcNow = now.toISOString().slice(0, -1).replace("T", " ");
|
||||||
const published = await db.transaction(async (tx) => {
|
const published = await db.transaction(async (tx) => {
|
||||||
const [rows] = await tx.execute(
|
const [rows] = await tx.execute(
|
||||||
sql`SELECT CAST(id AS CHAR) AS id,user_id AS userId,publish_at AS publishAt FROM website_articles WHERE status='scheduled' AND publish_at<=${now} ORDER BY publish_at,id LIMIT 100 FOR UPDATE`,
|
sql`SELECT CAST(id AS CHAR) AS id,user_id AS userId,publish_at AS publishAt FROM website_articles WHERE status='scheduled' AND publish_at<=${utcNow} ORDER BY publish_at,id LIMIT 100 FOR UPDATE`,
|
||||||
);
|
);
|
||||||
let published = 0;
|
let published = 0;
|
||||||
for (const article of rows as unknown as Array<{
|
for (const article of rows as unknown as Array<{
|
||||||
@@ -20,7 +22,7 @@ export async function publishDueArticles(now = new Date()): Promise<number> {
|
|||||||
publishAt: Date | string;
|
publishAt: Date | string;
|
||||||
}>) {
|
}>) {
|
||||||
const [result] = await tx.execute(
|
const [result] = await tx.execute(
|
||||||
sql`UPDATE website_articles SET status='published',published_at=${now},updated_at=${now} WHERE id=${article.id} AND status='scheduled' AND publish_at<=${now}`,
|
sql`UPDATE website_articles SET status='published',published_at=${utcNow},updated_at=${utcNow} WHERE id=${article.id} AND status='scheduled' AND publish_at<=${utcNow}`,
|
||||||
);
|
);
|
||||||
if ((result as unknown as { affectedRows: number }).affectedRows !== 1)
|
if ((result as unknown as { affectedRows: number }).affectedRows !== 1)
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -3919,7 +3919,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Photos",
|
"title": "Photos",
|
||||||
|
|||||||
@@ -297,7 +297,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Снимки",
|
"title": "Снимки",
|
||||||
|
|||||||
@@ -297,7 +297,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Fotografie",
|
"title": "Fotografie",
|
||||||
|
|||||||
@@ -297,7 +297,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Fotos",
|
"title": "Fotos",
|
||||||
|
|||||||
@@ -297,7 +297,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Fotos",
|
"title": "Fotos",
|
||||||
|
|||||||
@@ -297,7 +297,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Φωτογραφίες",
|
"title": "Φωτογραφίες",
|
||||||
|
|||||||
@@ -339,7 +339,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Photos",
|
"title": "Photos",
|
||||||
|
|||||||
@@ -297,7 +297,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Fotos",
|
"title": "Fotos",
|
||||||
|
|||||||
@@ -3919,7 +3919,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Photos",
|
"title": "Photos",
|
||||||
|
|||||||
@@ -297,7 +297,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Photos",
|
"title": "Photos",
|
||||||
|
|||||||
@@ -297,7 +297,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Fotografije",
|
"title": "Fotografije",
|
||||||
|
|||||||
@@ -297,7 +297,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Fényképek",
|
"title": "Fényképek",
|
||||||
|
|||||||
@@ -339,7 +339,15 @@
|
|||||||
"invalid": "Commento non valido.",
|
"invalid": "Commento non valido.",
|
||||||
"notFound": "Articolo non trovato.",
|
"notFound": "Articolo non trovato.",
|
||||||
"error": "Qualcosa è andato storto. Riprova."
|
"error": "Qualcosa è andato storto. Riprova."
|
||||||
}
|
},
|
||||||
|
"loadError": "Questo articolo è temporaneamente non disponibile.",
|
||||||
|
"retry": "Riprova",
|
||||||
|
"commentsUnavailable": "I commenti sono temporaneamente non disponibili.",
|
||||||
|
"reactionsUnavailable": "Le reazioni sono temporaneamente non disponibili.",
|
||||||
|
"commentsPagination": "Pagine dei commenti",
|
||||||
|
"previous": "Precedente",
|
||||||
|
"next": "Successiva",
|
||||||
|
"commentsPageSummary": "Pagina {page} di {pages} · {total} commenti"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Foto",
|
"title": "Foto",
|
||||||
|
|||||||
@@ -3919,7 +3919,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Photos",
|
"title": "Photos",
|
||||||
|
|||||||
@@ -339,7 +339,15 @@
|
|||||||
"invalid": "Ongeldige reactie.",
|
"invalid": "Ongeldige reactie.",
|
||||||
"notFound": "Dat artikel is niet gevonden.",
|
"notFound": "Dat artikel is niet gevonden.",
|
||||||
"error": "Er ging iets mis. Probeer het opnieuw."
|
"error": "Er ging iets mis. Probeer het opnieuw."
|
||||||
}
|
},
|
||||||
|
"loadError": "Dit artikel is tijdelijk niet beschikbaar.",
|
||||||
|
"retry": "Opnieuw proberen",
|
||||||
|
"commentsUnavailable": "Reacties zijn tijdelijk niet beschikbaar.",
|
||||||
|
"reactionsUnavailable": "Waarderingen zijn tijdelijk niet beschikbaar.",
|
||||||
|
"commentsPagination": "Reactiepagina’s",
|
||||||
|
"previous": "Vorige",
|
||||||
|
"next": "Volgende",
|
||||||
|
"commentsPageSummary": "Pagina {page} van {pages} · {total} reacties"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Foto's",
|
"title": "Foto's",
|
||||||
|
|||||||
@@ -297,7 +297,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Bilder",
|
"title": "Bilder",
|
||||||
|
|||||||
@@ -297,7 +297,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Zdjęcia",
|
"title": "Zdjęcia",
|
||||||
|
|||||||
@@ -297,7 +297,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Fotos",
|
"title": "Fotos",
|
||||||
|
|||||||
@@ -297,7 +297,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Fotografii",
|
"title": "Fotografii",
|
||||||
|
|||||||
@@ -297,7 +297,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Фото",
|
"title": "Фото",
|
||||||
|
|||||||
@@ -297,7 +297,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Fotografie",
|
"title": "Fotografie",
|
||||||
|
|||||||
@@ -297,7 +297,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Фотографије",
|
"title": "Фотографије",
|
||||||
|
|||||||
@@ -297,7 +297,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Foton",
|
"title": "Foton",
|
||||||
|
|||||||
@@ -297,7 +297,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Fotoğraflar",
|
"title": "Fotoğraflar",
|
||||||
|
|||||||
@@ -297,7 +297,15 @@
|
|||||||
"invalid": "Invalid comment.",
|
"invalid": "Invalid comment.",
|
||||||
"notFound": "That article was not found.",
|
"notFound": "That article was not found.",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again."
|
||||||
}
|
},
|
||||||
|
"loadError": "This article is temporarily unavailable.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"commentsUnavailable": "Comments are temporarily unavailable.",
|
||||||
|
"reactionsUnavailable": "Reactions are temporarily unavailable.",
|
||||||
|
"commentsPagination": "Comment pages",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"commentsPageSummary": "Page {page} of {pages} · {total} comments"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"title": "Фотографії",
|
"title": "Фотографії",
|
||||||
|
|||||||
Reference in new issue
Block a user