Skip to content

Commit b2a5bee

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/payment-record-tables
# Conflicts: # scripts/mutation/equivalent-mutants.txt
2 parents 67036ad + f5d6045 commit b2a5bee

3 files changed

Lines changed: 56 additions & 7 deletions

File tree

scripts/mutation/equivalent-mutants.txt

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,17 +111,18 @@ src/fp.ts:452:17 ++ → -- # either direction changes generation and invalida
111111
src/fp.ts:455:49 ?? → || # items?.length is undefined or a non-negative integer; 0 and undefined both produce 0
112112

113113
# Table inputs make these operators equivalent: nullable query results exclude
114-
# undefined, paired null checks already reject both nullish values, optional
115-
# conditions/dependencies are objects or arrays, and non-generated primary keys
116-
# are overwritten from the input-column fold in either initial-row branch.
117-
src/shared/db/table.ts:521:24 ?: → consequent only # generated keys use the consequent; non-generated keys are overwritten by their input-column value
118-
src/shared/db/table.ts:561:28 ?? → || # condition?.args is an array or undefined, and every array is truthy
119-
src/shared/db/table.ts:733:32 ?? → || # dependsOn is a readonly array or undefined, and every array is truthy
114+
# undefined, paired null checks already reject both nullish values, and optional
115+
# conditions/dependencies are objects or arrays.
116+
src/shared/db/table.ts:441:28 ?? → || # condition?.args is an array or undefined, and every array is truthy
117+
src/shared/db/table.ts:488:71 ?? → || # a found row is a truthy object, and a miss is undefined, so both operators give the row or null
118+
src/shared/db/table.ts:492:31 ?? → || # findByIds's element is a truthy row, null, or undefined, and all three agree
119+
src/shared/db/table.ts:580:21 ?? → || # dependsOn is a readonly array or undefined, and every array is truthy
120+
src/shared/db/table.ts:613:32 ?? → || # the cached table's dependsOn is the same array-or-undefined as above
120121

121122
# Login attempt rows store an integer count. For undefined, null, zero, or any
122123
# non-zero integer, `(attempts ?? 0) + 1` and `(attempts || 0) + 1` agree.
123124
src/shared/db/login-attempts.ts:46:39 ?? → ||
124-
src/shared/db/users.ts:263:62 ?? → || # queryAll()[0] is a truthy UserAuthFields object or undefined, so both operators return the row or null
125+
src/shared/db/users.ts:264:62 ?? → || # queryAll()[0] is a truthy UserAuthFields object or undefined, so both operators return the row or null
125126
src/shared/db/query-log.ts:71:62 ?? → || # queryLogScope.current() is a truthy QueryLogState object or undefined
126127
src/shared/db/query-log.ts:252:43 ?? → || # stored read counts start at 1 and remain positive, so only undefined reaches the zero fallback
127128
src/shared/db/query-log.ts:292:22 ?? → || # store is a truthy QueryLogState object or undefined
@@ -991,6 +992,10 @@ src/features/admin/attendee-page.ts:122:43 ?? → || # URLSearchParams.get re
991992
src/features/admin/attendees-list.ts:56:52 ?? → || # parsePositiveInt validates against PositiveIntSchema (minValue 1), so it returns a number of 1 or more, or null — it can never return 0, the only falsy number that would tell ?? and || apart
992993
src/features/admin/listing-page-data.ts:282:61 ?? → || # Map.get returns ListingWithCount[] | undefined, and every array — empty included — is truthy, so only undefined reaches the fallback and both operators yield the same []
993994

995+
# The news card query names a single table, so its columns need no alias to
996+
# resolve.
997+
src/shared/db/news-posts.ts:148:47 news_post → "" # dropping the alias leaves the column names unqualified, and the read selects from one table (news_posts AS news_post) plus scalar subqueries that carry their own qualified id, so every name still resolves to the same column
998+
994999
# A revision a row rule already makes unreachable.
9951000
src/shared/db/migrations/schema/payments/cases.ts:42:70 1 → 0 # alert_sent_revision can never be 0 while it is there: the row rule "alert_sent_revision IS NULL OR (alerted_revision IS NOT NULL AND alert_sent_revision = alerted_revision)" ties it to alerted_revision, whose own floor is 1, so the value is either missing (both floors pass) or at least 1
9961001

src/shared/db/news-posts.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import type {
5555
NewsPostCard,
5656
NewsPostSummary,
5757
} from "#shared/types.ts";
58+
import { isInstant } from "#shared/validation/timestamp.ts";
5859

5960
/** Create/update input (camelCase keys → snake_case columns). `created`, `slug`,
6061
* and `slugIndex` are computed in {@link createNewsPost}, never posted by the
@@ -193,6 +194,11 @@ export const createNewsPost = async (
193194
transaction?: TxScope,
194195
): Promise<NewsPost> => {
195196
const created = input.created ?? nowIso();
197+
// The permalink is built from this date and can never be rebuilt, so a caller
198+
// that pins a date it made up gets an error rather than a broken /news link.
199+
if (!isInstant(created)) {
200+
throw new Error(`News post created is not a real timestamp: "${created}"`);
201+
}
196202
return useTransaction(transaction, async (tx) => {
197203
const { slug, slugIndex } = await uniqueSlugFromBase({
198204
base: newsSlugBase(created, input.name),

test/shared/db/news-posts.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
getNewsPostBySlugIndex,
1010
getNewsPostCards,
1111
getNewsPostNames,
12+
getNewsPostSummaries,
1213
hasNewsPosts,
1314
updateNewsPost,
1415
} from "#shared/db/news-posts.ts";
@@ -237,6 +238,43 @@ describeWithEnv("db > news-posts", { db: true }, () => {
237238
});
238239
});
239240

241+
describe("createNewsPost", () => {
242+
// The /news permalink is built from this date and is never rebuilt, so a
243+
// date that isn't a real moment has to fail before the post is written.
244+
test("rejects a pinned created date that is not a real timestamp", async () => {
245+
for (const created of ["", "not a date", "2026-02-30T10:00:00.000Z"]) {
246+
await expect(
247+
createTestNewsPost("Bad date", { created }),
248+
).rejects.toThrow("News post created is not a real timestamp");
249+
}
250+
expect(await hasNewsPosts()).toBe(false);
251+
});
252+
});
253+
254+
describe("getNewsPostSummaries", () => {
255+
// The news feed and the admin news page both show these summaries in the
256+
// order they come back, so newest-first is part of what this returns.
257+
test("lists newest first, most recently created of a shared day first", async () => {
258+
await createTestNewsPost("Oldest", {
259+
created: "2026-07-01T10:00:00.000Z",
260+
});
261+
await createTestNewsPost("Same day, written first", {
262+
created: "2026-07-03T10:00:00.000Z",
263+
});
264+
await createTestNewsPost("Same day, written second", {
265+
created: "2026-07-03T10:00:00.000Z",
266+
});
267+
268+
const summaries = await getNewsPostSummaries();
269+
270+
expect(summaries.map((summary) => summary.name)).toEqual([
271+
"Same day, written second",
272+
"Same day, written first",
273+
"Oldest",
274+
]);
275+
});
276+
});
277+
240278
describe("getNewsPostNames", () => {
241279
test("maps id to decrypted name", async () => {
242280
const post = await createTestNewsPost("Named post");

0 commit comments

Comments
 (0)