b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
779 lines
24 KiB
TypeScript
779 lines
24 KiB
TypeScript
import type { Kysely } from "kysely";
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
|
|
import { BylineRepository } from "../../../src/database/repositories/byline.js";
|
|
import { ContentRepository } from "../../../src/database/repositories/content.js";
|
|
import { UserRepository } from "../../../src/database/repositories/user.js";
|
|
import type { Database } from "../../../src/database/types.js";
|
|
import { setI18nConfig } from "../../../src/i18n/config.js";
|
|
import { SQL_BATCH_SIZE } from "../../../src/utils/chunks.js";
|
|
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js";
|
|
|
|
// Mock the loader's getDb to return our test database
|
|
vi.mock("../../../src/loader.js", () => ({
|
|
getDb: vi.fn(),
|
|
}));
|
|
|
|
import {
|
|
getByline,
|
|
getBylineBySlug,
|
|
getEntryBylines,
|
|
getBylinesForEntries,
|
|
} from "../../../src/bylines/index.js";
|
|
import { getDb } from "../../../src/loader.js";
|
|
|
|
describe("Byline query functions", () => {
|
|
let db: Kysely<Database>;
|
|
let bylineRepo: BylineRepository;
|
|
let contentRepo: ContentRepository;
|
|
|
|
beforeEach(async () => {
|
|
db = await setupTestDatabaseWithCollections();
|
|
bylineRepo = new BylineRepository(db);
|
|
contentRepo = new ContentRepository(db);
|
|
vi.mocked(getDb).mockResolvedValue(db);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await teardownTestDatabase(db);
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("getByline", () => {
|
|
it("returns a byline by ID", async () => {
|
|
const created = await bylineRepo.create({
|
|
slug: "jane-doe",
|
|
displayName: "Jane Doe",
|
|
});
|
|
|
|
const result = await getByline(created.id);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.id).toBe(created.id);
|
|
expect(result?.displayName).toBe("Jane Doe");
|
|
expect(result?.slug).toBe("jane-doe");
|
|
});
|
|
|
|
it("returns null for non-existent ID", async () => {
|
|
const result = await getByline("non-existent");
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("getBylineBySlug", () => {
|
|
it("returns a byline by slug", async () => {
|
|
await bylineRepo.create({
|
|
slug: "john-smith",
|
|
displayName: "John Smith",
|
|
});
|
|
|
|
const result = await getBylineBySlug("john-smith");
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.displayName).toBe("John Smith");
|
|
});
|
|
|
|
it("returns null for non-existent slug", async () => {
|
|
const result = await getBylineBySlug("nobody");
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("walks the locale fallback chain when the requested locale has no row", async () => {
|
|
// Identity lookup pattern — mirrors getTerm / getMenu in PR #916.
|
|
// Locale fallback IS appropriate here ("show me this byline") in
|
|
// contrast to credit hydration ("what should render on this
|
|
// entry"), which stays strict.
|
|
setI18nConfig({ defaultLocale: "en", locales: ["en", "fr", "de"] });
|
|
try {
|
|
const anchor = await bylineRepo.create({
|
|
slug: "jane",
|
|
displayName: "Jane Doe",
|
|
locale: "en",
|
|
});
|
|
await bylineRepo.create({
|
|
slug: "jane",
|
|
displayName: "Jeanne",
|
|
locale: "fr",
|
|
translationOf: anchor.id,
|
|
});
|
|
// No de sibling. Requesting de falls back through the chain to
|
|
// the configured defaultLocale (en).
|
|
const result = await getBylineBySlug("jane", { locale: "de" });
|
|
expect(result?.locale).toBe("en");
|
|
expect(result?.displayName).toBe("Jane Doe");
|
|
} finally {
|
|
setI18nConfig(null);
|
|
}
|
|
});
|
|
|
|
it("prefers the requested locale's row when it exists", async () => {
|
|
setI18nConfig({ defaultLocale: "en", locales: ["en", "fr", "de"] });
|
|
try {
|
|
const anchor = await bylineRepo.create({
|
|
slug: "jane",
|
|
displayName: "Jane Doe",
|
|
locale: "en",
|
|
});
|
|
await bylineRepo.create({
|
|
slug: "jane",
|
|
displayName: "Jeanne",
|
|
locale: "fr",
|
|
translationOf: anchor.id,
|
|
});
|
|
const result = await getBylineBySlug("jane", { locale: "fr" });
|
|
expect(result?.locale).toBe("fr");
|
|
expect(result?.displayName).toBe("Jeanne");
|
|
} finally {
|
|
setI18nConfig(null);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("getEntryBylines", () => {
|
|
it("returns explicit byline credits for an entry", async () => {
|
|
const lead = await bylineRepo.create({
|
|
slug: "lead-author",
|
|
displayName: "Lead Author",
|
|
});
|
|
const editor = await bylineRepo.create({
|
|
slug: "editor",
|
|
displayName: "Editor",
|
|
});
|
|
|
|
const post = await contentRepo.create({
|
|
type: "post",
|
|
slug: "my-post",
|
|
data: { title: "My Post" },
|
|
});
|
|
|
|
await bylineRepo.setContentBylines("post", post.id, [
|
|
{ bylineId: lead.id },
|
|
{ bylineId: editor.id, roleLabel: "Contributing Editor" },
|
|
]);
|
|
|
|
const bylines = await getEntryBylines("post", post.id);
|
|
|
|
expect(bylines).toHaveLength(2);
|
|
expect(bylines[0]?.byline.displayName).toBe("Lead Author");
|
|
expect(bylines[0]?.sortOrder).toBe(0);
|
|
expect(bylines[0]?.source).toBe("explicit");
|
|
expect(bylines[1]?.byline.displayName).toBe("Editor");
|
|
expect(bylines[1]?.roleLabel).toBe("Contributing Editor");
|
|
expect(bylines[1]?.source).toBe("explicit");
|
|
});
|
|
|
|
it("falls back to user-linked byline when no explicit credits", async () => {
|
|
// Create a user
|
|
const userRepo = new UserRepository(db);
|
|
const user = await userRepo.create({
|
|
email: "author@example.com",
|
|
displayName: "Author User",
|
|
role: "editor",
|
|
});
|
|
|
|
// Create a byline linked to the user
|
|
await bylineRepo.create({
|
|
slug: "author-user",
|
|
displayName: "Author User",
|
|
userId: user.id,
|
|
});
|
|
|
|
// Create a post with this user as author, no explicit bylines
|
|
const post = await contentRepo.create({
|
|
type: "post",
|
|
slug: "authored-post",
|
|
data: { title: "Authored Post" },
|
|
authorId: user.id,
|
|
});
|
|
|
|
const bylines = await getEntryBylines("post", post.id);
|
|
|
|
expect(bylines).toHaveLength(1);
|
|
expect(bylines[0]?.byline.displayName).toBe("Author User");
|
|
expect(bylines[0]?.source).toBe("inferred");
|
|
expect(bylines[0]?.roleLabel).toBeNull();
|
|
});
|
|
|
|
it("returns empty array when no bylines and no author fallback", async () => {
|
|
const post = await contentRepo.create({
|
|
type: "post",
|
|
slug: "no-author-post",
|
|
data: { title: "No Author" },
|
|
});
|
|
|
|
const bylines = await getEntryBylines("post", post.id);
|
|
expect(bylines).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("getEntryBylines — explicit credit sentinel", () => {
|
|
it("suppresses author fallback for cross-locale explicit credits with no i18n config", async () => {
|
|
setI18nConfig(null);
|
|
|
|
const userRepo = new UserRepository(db);
|
|
const author = await userRepo.create({
|
|
email: "cross-locale-no-i18n@example.com",
|
|
displayName: "Author Account",
|
|
role: "editor",
|
|
});
|
|
await bylineRepo.create({
|
|
slug: "author-en",
|
|
displayName: "Author EN",
|
|
userId: author.id,
|
|
locale: "en",
|
|
});
|
|
|
|
const credited = await bylineRepo.create({
|
|
slug: "credited",
|
|
displayName: "Credited FR",
|
|
locale: "fr",
|
|
});
|
|
|
|
const enPost = await contentRepo.create({
|
|
type: "post",
|
|
slug: "en-post-cross-locale",
|
|
data: { title: "Hello" },
|
|
locale: "en",
|
|
authorId: author.id,
|
|
});
|
|
await bylineRepo.setContentBylines("post", enPost.id, [{ bylineId: credited.id }]);
|
|
|
|
const result = await getEntryBylines("post", enPost.id, { locale: "en" });
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("does not run a probe query against _emdash_content_bylines", async () => {
|
|
setI18nConfig(null);
|
|
|
|
const userRepo = new UserRepository(db);
|
|
const author = await userRepo.create({
|
|
email: "no-probe@example.com",
|
|
displayName: "Author",
|
|
role: "editor",
|
|
});
|
|
await bylineRepo.create({
|
|
slug: "no-probe-author",
|
|
displayName: "Author Byline",
|
|
userId: author.id,
|
|
});
|
|
|
|
const post = await contentRepo.create({
|
|
type: "post",
|
|
slug: "no-probe-post",
|
|
data: { title: "No explicit credits" },
|
|
authorId: author.id,
|
|
});
|
|
|
|
const single = vi.spyOn(BylineRepository.prototype, "hasContentBylines");
|
|
const batch = vi.spyOn(BylineRepository.prototype, "hasContentBylinesMany");
|
|
await getEntryBylines("post", post.id);
|
|
await getBylinesForEntries("post", [
|
|
{ id: post.id, authorId: author.id, primaryBylineId: null },
|
|
]);
|
|
expect(single).not.toHaveBeenCalled();
|
|
expect(batch).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("getBylinesForEntries", () => {
|
|
it("batch-fetches byline credits for multiple entries", async () => {
|
|
const author1 = await bylineRepo.create({
|
|
slug: "author-one",
|
|
displayName: "Author One",
|
|
});
|
|
const author2 = await bylineRepo.create({
|
|
slug: "author-two",
|
|
displayName: "Author Two",
|
|
});
|
|
|
|
const post1 = await contentRepo.create({
|
|
type: "post",
|
|
slug: "post-1",
|
|
data: { title: "Post 1" },
|
|
});
|
|
const post2 = await contentRepo.create({
|
|
type: "post",
|
|
slug: "post-2",
|
|
data: { title: "Post 2" },
|
|
});
|
|
const post3 = await contentRepo.create({
|
|
type: "post",
|
|
slug: "post-3",
|
|
data: { title: "Post 3" },
|
|
});
|
|
|
|
await bylineRepo.setContentBylines("post", post1.id, [{ bylineId: author1.id }]);
|
|
await bylineRepo.setContentBylines("post", post2.id, [
|
|
{ bylineId: author1.id },
|
|
{ bylineId: author2.id, roleLabel: "Contributor" },
|
|
]);
|
|
// post3 has no bylines
|
|
|
|
const result = await getBylinesForEntries(
|
|
"post",
|
|
[post1, post2, post3].map((p) => ({ id: p.id, authorId: p.authorId })),
|
|
);
|
|
|
|
expect(result.get(post1.id)).toHaveLength(1);
|
|
expect(result.get(post1.id)?.[0]?.byline.displayName).toBe("Author One");
|
|
expect(result.get(post1.id)?.[0]?.source).toBe("explicit");
|
|
|
|
expect(result.get(post2.id)).toHaveLength(2);
|
|
expect(result.get(post2.id)?.[0]?.byline.displayName).toBe("Author One");
|
|
expect(result.get(post2.id)?.[1]?.byline.displayName).toBe("Author Two");
|
|
expect(result.get(post2.id)?.[1]?.roleLabel).toBe("Contributor");
|
|
|
|
expect(result.get(post3.id)).toHaveLength(0);
|
|
});
|
|
|
|
it("returns inferred bylines for entries without explicit credits", async () => {
|
|
const userRepo = new UserRepository(db);
|
|
const user = await userRepo.create({
|
|
email: "batch-author@example.com",
|
|
displayName: "Batch Author",
|
|
role: "editor",
|
|
});
|
|
|
|
await bylineRepo.create({
|
|
slug: "batch-author",
|
|
displayName: "Batch Author",
|
|
userId: user.id,
|
|
});
|
|
|
|
const post = await contentRepo.create({
|
|
type: "post",
|
|
slug: "batch-post",
|
|
data: { title: "Batch Post" },
|
|
authorId: user.id,
|
|
});
|
|
|
|
const result = await getBylinesForEntries("post", [{ id: post.id, authorId: post.authorId }]);
|
|
|
|
expect(result.get(post.id)).toHaveLength(1);
|
|
expect(result.get(post.id)?.[0]?.source).toBe("inferred");
|
|
expect(result.get(post.id)?.[0]?.byline.displayName).toBe("Batch Author");
|
|
});
|
|
|
|
it("hydrates customFields on author-fallback bylines across disjoint locale buckets", async () => {
|
|
// Regression for the Phase 3 review's "author-fallback still
|
|
// calls findByUserIds per bucket and hydrates inside each
|
|
// call" finding. Even with the explicit-credit path batched,
|
|
// the per-bucket findByUserIds calls each fired their own
|
|
// group-shared query for the author's translation_group — and
|
|
// when authors differ per locale (the disjoint case), the
|
|
// groups don't overlap, so the per-bucket caches don't help.
|
|
//
|
|
// Fix: `findByUserIds` gains `skipHydration`, the author-
|
|
// fallback path uses it, and the fallback bylines feed into
|
|
// the same `hydrationTargets` array as the explicit credits.
|
|
// One batched `hydrateBylineCustomFields` covers all bylines
|
|
// at the end of `getBylinesForEntries`.
|
|
const userRepo = new UserRepository(db);
|
|
|
|
// Two distinct authors (disjoint identities → disjoint
|
|
// translation_groups), one credited in each locale.
|
|
const userEn = await userRepo.create({
|
|
email: "en-author@example.com",
|
|
displayName: "EN Author",
|
|
role: "editor",
|
|
});
|
|
const userFr = await userRepo.create({
|
|
email: "fr-author@example.com",
|
|
displayName: "FR Author",
|
|
role: "editor",
|
|
});
|
|
|
|
const bylineEn = await bylineRepo.create({
|
|
slug: "en-author",
|
|
displayName: "EN Author",
|
|
userId: userEn.id,
|
|
locale: "en",
|
|
});
|
|
const bylineFr = await bylineRepo.create({
|
|
slug: "fr-author",
|
|
displayName: "FR Author",
|
|
userId: userFr.id,
|
|
locale: "fr",
|
|
});
|
|
|
|
// Register a non-translatable byline custom field and seed
|
|
// distinct values for each author's translation_group. Verifying
|
|
// that BOTH authors' values surface after a single
|
|
// getBylinesForEntries call proves the batched hydration
|
|
// covers the fallback path.
|
|
const { BylineSchemaRegistry } = await import("../../../src/schema/byline-registry.js");
|
|
const { sql } = await import("kysely");
|
|
const registry = new BylineSchemaRegistry(db);
|
|
await registry.createField({
|
|
slug: "twitter_handle",
|
|
label: "Twitter",
|
|
type: "string",
|
|
translatable: false,
|
|
});
|
|
const field = await registry.getField("twitter_handle");
|
|
|
|
const enGroup = bylineEn.translationGroup ?? bylineEn.id;
|
|
const frGroup = bylineFr.translationGroup ?? bylineFr.id;
|
|
expect(enGroup).not.toBe(frGroup);
|
|
await sql`
|
|
INSERT INTO _emdash_byline_field_group_values (translation_group, field_id, value)
|
|
VALUES (${enGroup}, ${field?.id}, '"@en"'),
|
|
(${frGroup}, ${field?.id}, '"@fr"')
|
|
`.execute(db);
|
|
const { resetBylineFieldDefsCacheForTests } =
|
|
await import("../../../src/bylines/field-defs-cache.js");
|
|
resetBylineFieldDefsCacheForTests();
|
|
|
|
const enPost = await contentRepo.create({
|
|
type: "post",
|
|
slug: "en-post",
|
|
data: { title: "EN" },
|
|
locale: "en",
|
|
authorId: userEn.id,
|
|
});
|
|
const frPost = await contentRepo.create({
|
|
type: "post",
|
|
slug: "fr-post",
|
|
data: { title: "FR" },
|
|
locale: "fr",
|
|
authorId: userFr.id,
|
|
});
|
|
|
|
// Count queries against `_emdash_byline_field_group_values`
|
|
// to assert the AC envelope holds. The pre-fix path fired one
|
|
// query per locale bucket (2 here, since the buckets reference
|
|
// disjoint translation_groups). The fix defers fallback
|
|
// hydration into the single batched call and lands at exactly
|
|
// one query for the whole pass.
|
|
let groupValueQueries = 0;
|
|
const originalSelectFrom = db.selectFrom.bind(db);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- spy on a Kysely chain entry point; the chain values flow through unchanged
|
|
const selectFromSpy = vi.spyOn(db, "selectFrom").mockImplementation(((table: any) => {
|
|
if (table === "_emdash_byline_field_group_values") groupValueQueries += 1;
|
|
return originalSelectFrom(table);
|
|
}) as any);
|
|
|
|
let result;
|
|
try {
|
|
result = await getBylinesForEntries("post", [
|
|
{ id: enPost.id, locale: "en", authorId: enPost.authorId },
|
|
{ id: frPost.id, locale: "fr", authorId: frPost.authorId },
|
|
]);
|
|
} finally {
|
|
selectFromSpy.mockRestore();
|
|
}
|
|
|
|
const enCredits = result.get(enPost.id) ?? [];
|
|
const frCredits = result.get(frPost.id) ?? [];
|
|
expect(enCredits[0]?.source).toBe("inferred");
|
|
expect(frCredits[0]?.source).toBe("inferred");
|
|
// Both bylines must carry their group-shared customFields.
|
|
expect(enCredits[0]?.byline.customFields?.twitter_handle).toBe("@en");
|
|
expect(frCredits[0]?.byline.customFields?.twitter_handle).toBe("@fr");
|
|
|
|
// Phase 3 AC #8: "+1 batched query per hydration pass" for
|
|
// group-shared values — meaning one query for the entire
|
|
// `getBylinesForEntries` call regardless of bucket count. The
|
|
// pre-fix author-fallback path issued one per bucket; this
|
|
// asserts it now lands at one.
|
|
expect(groupValueQueries).toBe(1);
|
|
});
|
|
|
|
it("handles batches larger than SQL_BATCH_SIZE across explicit and inferred bylines", async () => {
|
|
const userRepo = new UserRepository(db);
|
|
const explicitByline = await bylineRepo.create({
|
|
slug: "large-batch-explicit",
|
|
displayName: "Large Batch Explicit",
|
|
});
|
|
|
|
const explicitPost1 = await contentRepo.create({
|
|
type: "post",
|
|
slug: "large-batch-explicit-1",
|
|
data: { title: "Large Batch Explicit 1" },
|
|
});
|
|
await bylineRepo.setContentBylines("post", explicitPost1.id, [
|
|
{ bylineId: explicitByline.id },
|
|
]);
|
|
|
|
const inferredPosts: { id: string; authorId: string | null }[] = [];
|
|
for (let i = 0; i < SQL_BATCH_SIZE + 2; i++) {
|
|
const user = await userRepo.create({
|
|
email: `large-batch-${i}@example.com`,
|
|
displayName: `Large Batch ${i}`,
|
|
role: "editor",
|
|
});
|
|
|
|
await bylineRepo.create({
|
|
slug: `large-batch-${i}`,
|
|
displayName: `Large Batch ${i}`,
|
|
userId: user.id,
|
|
});
|
|
|
|
const post = await contentRepo.create({
|
|
type: "post",
|
|
slug: `large-batch-post-${i}`,
|
|
data: { title: `Large Batch Post ${i}` },
|
|
authorId: user.id,
|
|
});
|
|
inferredPosts.push({ id: post.id, authorId: post.authorId });
|
|
}
|
|
|
|
const explicitPost2 = await contentRepo.create({
|
|
type: "post",
|
|
slug: "large-batch-explicit-2",
|
|
data: { title: "Large Batch Explicit 2" },
|
|
});
|
|
await bylineRepo.setContentBylines("post", explicitPost2.id, [
|
|
{ bylineId: explicitByline.id },
|
|
]);
|
|
|
|
const inferredPostIds = inferredPosts.map((p) => p.id);
|
|
const entries = [
|
|
{ id: explicitPost1.id, authorId: explicitPost1.authorId },
|
|
...inferredPosts,
|
|
{ id: explicitPost2.id, authorId: explicitPost2.authorId },
|
|
];
|
|
const result = await getBylinesForEntries("post", entries);
|
|
|
|
expect(result.size).toBe(entries.length);
|
|
expect(result.get(explicitPost1.id)?.[0]?.source).toBe("explicit");
|
|
expect(result.get(explicitPost1.id)?.[0]?.byline.displayName).toBe("Large Batch Explicit");
|
|
expect(result.get(explicitPost2.id)?.[0]?.source).toBe("explicit");
|
|
expect(result.get(explicitPost2.id)?.[0]?.byline.displayName).toBe("Large Batch Explicit");
|
|
expect(result.get(inferredPostIds[0]!)?.[0]?.source).toBe("inferred");
|
|
expect(result.get(inferredPostIds[0]!)?.[0]?.byline.displayName).toBe("Large Batch 0");
|
|
expect(result.get(inferredPostIds[SQL_BATCH_SIZE + 1]!)?.[0]?.source).toBe("inferred");
|
|
expect(result.get(inferredPostIds[SQL_BATCH_SIZE + 1]!)?.[0]?.byline.displayName).toBe(
|
|
`Large Batch ${SQL_BATCH_SIZE + 1}`,
|
|
);
|
|
});
|
|
|
|
it("returns empty map for empty input", async () => {
|
|
const result = await getBylinesForEntries("post", []);
|
|
expect(result.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("i18n (migration 040) — strict per-locale runtime", () => {
|
|
it("getEntryBylines({ locale }) returns the sibling at that locale", async () => {
|
|
const anchor = await bylineRepo.create({
|
|
slug: "jane",
|
|
displayName: "Jane Doe",
|
|
locale: "en",
|
|
});
|
|
await bylineRepo.create({
|
|
slug: "jane",
|
|
displayName: "Jeanne",
|
|
locale: "fr",
|
|
translationOf: anchor.id,
|
|
});
|
|
|
|
const post = await contentRepo.create({
|
|
type: "post",
|
|
slug: "i18n-post",
|
|
data: { title: "Hi" },
|
|
});
|
|
await bylineRepo.setContentBylines("post", post.id, [{ bylineId: anchor.id }]);
|
|
|
|
const enCredits = await getEntryBylines("post", post.id, { locale: "en" });
|
|
const frCredits = await getEntryBylines("post", post.id, { locale: "fr" });
|
|
|
|
expect(enCredits[0]?.byline.displayName).toBe("Jane Doe");
|
|
expect(frCredits[0]?.byline.displayName).toBe("Jeanne");
|
|
});
|
|
|
|
it("getEntryBylines({ locale }) returns [] when no sibling exists at that locale", async () => {
|
|
const anchor = await bylineRepo.create({
|
|
slug: "marco",
|
|
displayName: "Marco",
|
|
locale: "en",
|
|
});
|
|
|
|
const post = await contentRepo.create({
|
|
type: "post",
|
|
slug: "marco-post",
|
|
data: { title: "Hi" },
|
|
});
|
|
await bylineRepo.setContentBylines("post", post.id, [{ bylineId: anchor.id }]);
|
|
|
|
const frCredits = await getEntryBylines("post", post.id, { locale: "fr" });
|
|
expect(frCredits).toEqual([]);
|
|
});
|
|
|
|
it("getEntryBylines without locale returns every locale variant of the credit", async () => {
|
|
const anchor = await bylineRepo.create({
|
|
slug: "jane",
|
|
displayName: "Jane Doe",
|
|
locale: "en",
|
|
});
|
|
await bylineRepo.create({
|
|
slug: "jane",
|
|
displayName: "Jeanne",
|
|
locale: "fr",
|
|
translationOf: anchor.id,
|
|
});
|
|
|
|
const post = await contentRepo.create({
|
|
type: "post",
|
|
slug: "no-locale",
|
|
data: { title: "Hi" },
|
|
});
|
|
await bylineRepo.setContentBylines("post", post.id, [{ bylineId: anchor.id }]);
|
|
|
|
const all = await getEntryBylines("post", post.id);
|
|
expect(all).toHaveLength(2);
|
|
expect(all.map((c) => c.byline.locale).toSorted()).toEqual(["en", "fr"]);
|
|
});
|
|
|
|
it("getBylinesForEntries buckets by entry locale and returns each entry's locale match", async () => {
|
|
const anchor = await bylineRepo.create({
|
|
slug: "jane",
|
|
displayName: "Jane Doe",
|
|
locale: "en",
|
|
});
|
|
await bylineRepo.create({
|
|
slug: "jane",
|
|
displayName: "Jeanne",
|
|
locale: "fr",
|
|
translationOf: anchor.id,
|
|
});
|
|
|
|
const enPost = await contentRepo.create({
|
|
type: "post",
|
|
slug: "en-jane",
|
|
data: { title: "Hello" },
|
|
locale: "en",
|
|
});
|
|
const frPost = await contentRepo.create({
|
|
type: "post",
|
|
slug: "fr-jane",
|
|
data: { title: "Bonjour" },
|
|
locale: "fr",
|
|
});
|
|
await bylineRepo.setContentBylines("post", enPost.id, [{ bylineId: anchor.id }]);
|
|
await bylineRepo.setContentBylines("post", frPost.id, [{ bylineId: anchor.id }]);
|
|
|
|
const result = await getBylinesForEntries("post", [
|
|
{ id: enPost.id, authorId: null, locale: "en" },
|
|
{ id: frPost.id, authorId: null, locale: "fr" },
|
|
]);
|
|
|
|
expect(result.get(enPost.id)?.[0]?.byline.locale).toBe("en");
|
|
expect(result.get(enPost.id)?.[0]?.byline.displayName).toBe("Jane Doe");
|
|
expect(result.get(frPost.id)?.[0]?.byline.locale).toBe("fr");
|
|
expect(result.get(frPost.id)?.[0]?.byline.displayName).toBe("Jeanne");
|
|
});
|
|
|
|
it("getBylinesForEntries returns [] for entries whose credit has no sibling at the entry's locale", async () => {
|
|
const anchor = await bylineRepo.create({
|
|
slug: "solo",
|
|
displayName: "Solo",
|
|
locale: "en",
|
|
});
|
|
|
|
const frPost = await contentRepo.create({
|
|
type: "post",
|
|
slug: "fr-solo",
|
|
data: { title: "Bonjour" },
|
|
locale: "fr",
|
|
});
|
|
await bylineRepo.setContentBylines("post", frPost.id, [{ bylineId: anchor.id }]);
|
|
|
|
const result = await getBylinesForEntries("post", [
|
|
{ id: frPost.id, authorId: null, locale: "fr" },
|
|
]);
|
|
expect(result.get(frPost.id)).toEqual([]);
|
|
});
|
|
|
|
it("does not infer from authorId when entry has explicit credits at another locale", async () => {
|
|
const userRepo = new UserRepository(db);
|
|
const user = await userRepo.create({
|
|
email: "runtime-fallback@example.com",
|
|
displayName: "Author",
|
|
role: "editor",
|
|
});
|
|
await bylineRepo.create({
|
|
slug: "author-fr",
|
|
displayName: "Auteur",
|
|
userId: user.id,
|
|
locale: "fr",
|
|
});
|
|
|
|
const explicit = await bylineRepo.create({
|
|
slug: "marco",
|
|
displayName: "Marco",
|
|
locale: "en",
|
|
});
|
|
|
|
const frPost = await contentRepo.create({
|
|
type: "post",
|
|
slug: "fr-post-explicit",
|
|
data: { title: "Bonjour" },
|
|
locale: "fr",
|
|
authorId: user.id,
|
|
});
|
|
await bylineRepo.setContentBylines("post", frPost.id, [{ bylineId: explicit.id }]);
|
|
|
|
const refreshed = await contentRepo.findById("post", frPost.id);
|
|
|
|
const single = await getEntryBylines("post", frPost.id, { locale: "fr" });
|
|
expect(single).toEqual([]);
|
|
|
|
const batch = await getBylinesForEntries("post", [
|
|
{
|
|
id: frPost.id,
|
|
authorId: frPost.authorId,
|
|
primaryBylineId: refreshed?.primaryBylineId ?? null,
|
|
locale: "fr",
|
|
},
|
|
]);
|
|
expect(batch.get(frPost.id)).toEqual([]);
|
|
});
|
|
|
|
it("inferred (user-linked) byline is strict per locale too", async () => {
|
|
const userRepo = new UserRepository(db);
|
|
const user = await userRepo.create({
|
|
email: "inferred-i18n@example.com",
|
|
displayName: "Inferred",
|
|
role: "editor",
|
|
});
|
|
|
|
await bylineRepo.create({
|
|
slug: "inferred",
|
|
displayName: "Inferred EN",
|
|
userId: user.id,
|
|
locale: "en",
|
|
});
|
|
|
|
const frPost = await contentRepo.create({
|
|
type: "post",
|
|
slug: "fr-inferred",
|
|
data: { title: "Bonjour" },
|
|
locale: "fr",
|
|
authorId: user.id,
|
|
});
|
|
|
|
// No fr sibling exists for the user's byline. Strict hydration
|
|
// returns no inferred credit — same rule as explicit credits.
|
|
const result = await getBylinesForEntries("post", [
|
|
{ id: frPost.id, authorId: frPost.authorId, locale: "fr" },
|
|
]);
|
|
expect(result.get(frPost.id)).toEqual([]);
|
|
|
|
// EN entry by the same author DOES get the inferred byline.
|
|
const enPost = await contentRepo.create({
|
|
type: "post",
|
|
slug: "en-inferred",
|
|
data: { title: "Hello" },
|
|
locale: "en",
|
|
authorId: user.id,
|
|
});
|
|
const enResult = await getBylinesForEntries("post", [
|
|
{ id: enPost.id, authorId: enPost.authorId, locale: "en" },
|
|
]);
|
|
expect(enResult.get(enPost.id)?.[0]?.source).toBe("inferred");
|
|
expect(enResult.get(enPost.id)?.[0]?.byline.locale).toBe("en");
|
|
});
|
|
});
|
|
});
|