Skip to content

Commit c486214

Browse files
authored
Fix try-out content read model (#172)
1 parent 65fca8d commit c486214

8 files changed

Lines changed: 493 additions & 77 deletions

File tree

apps/www/app/[locale]/(app)/(shared)/try-out/[product]/[slug]/part/[partKey]/body.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,6 @@ export async function TryoutPartBody({
123123

124124
const exercises = await getTryoutExercises(locale, contentPart.setSlug);
125125

126-
if (exercises.length === 0) {
127-
notFound();
128-
}
129-
130126
const material = parseExercisesMaterial(contentPart.material);
131127
const partIcon = Option.isSome(material)
132128
? getMaterialIcon(material.value)

apps/www/app/[locale]/(app)/(shared)/try-out/[product]/[slug]/part/[partKey]/data.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
11
import { api } from "@repo/backend/convex/_generated/api";
22
import type { TryoutProduct } from "@repo/backend/convex/tryouts/products";
3-
import { getRenderableExercisesContent } from "@repo/contents/_lib/exercises/renderable";
43
import { fetchQuery } from "convex/nextjs";
5-
import { Effect } from "effect";
64
import { cacheLife } from "next/cache";
75
import type { Locale } from "next-intl";
86

97
/**
108
* Loads the public tryout details for one part route from the Convex read model.
119
*
1210
* Convex content sync can publish this read model after a web deployment, so the
13-
* cache must stay short-lived instead of allowing a temporary miss to become a
11+
* cache stays short-lived instead of letting a temporary miss become a
1412
* persistent prerendered 404.
1513
*
16-
* Docs: https://nextjs.org/docs/app/api-reference/functions/cacheLife#prerendering-behavior
14+
* Docs: https://nextjs.org/docs/app/api-reference/functions/cacheLife#preset-cache-profiles
1715
*/
1816
export async function getTryoutPartData(
1917
locale: Locale,
@@ -51,13 +49,31 @@ export async function getTryoutPartData(
5149
};
5250
}
5351

54-
/** Loads one tryout exercise set as serializable exercise rows. */
52+
/**
53+
* Loads one synced tryout exercise set from the Convex content read model.
54+
*
55+
* The rendered choices must stay close to the live Convex answer sheet used by
56+
* the client runtime because answer submission maps visible choices to option
57+
* keys by order.
58+
*
59+
* Docs: https://docs.convex.dev/client/react#fetching-data
60+
*/
5561
export async function getTryoutExercises(locale: Locale, setSlug: string) {
5662
"use cache";
5763

58-
cacheLife("max");
64+
cacheLife("seconds");
5965

60-
return await Effect.runPromise(
61-
getRenderableExercisesContent(locale, setSlug)
66+
const exercises = await fetchQuery(
67+
api.exercises.queries.getRenderableRowsBySlug,
68+
{
69+
locale,
70+
slug: setSlug,
71+
}
6272
);
73+
74+
if (!exercises) {
75+
throw new Error(`Synced exercise set is missing for tryout: ${setSlug}`);
76+
}
77+
78+
return exercises;
6379
}

apps/www/components/exercise/entry.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { QuestionAnalytics } from "@/components/exercise/item/analytics";
66
import { ExerciseArticle } from "@/components/exercise/item/article";
77
import { importContentModuleOrNull } from "@/lib/content/module";
88

9+
type ExerciseEntryData = Pick<ExerciseWithoutDefaults, "choices" | "number">;
10+
911
/** Loads the compiled question module for one exercise entry. */
1012
async function QuestionContent({
1113
exerciseNumber,
@@ -62,7 +64,7 @@ function ExerciseEntryBody({
6264
setPath,
6365
srLabel,
6466
}: {
65-
exercise: ExerciseWithoutDefaults;
67+
exercise: ExerciseEntryData;
6668
id: string;
6769
locale: Locale;
6870
setPath: string;
@@ -101,7 +103,7 @@ export function ExerciseEntry({
101103
setPath,
102104
srLabel,
103105
}: {
104-
exercise: ExerciseWithoutDefaults;
106+
exercise: ExerciseEntryData;
105107
locale: Locale;
106108
setPath: string;
107109
srLabel: string;
@@ -126,7 +128,7 @@ export function ExerciseTrackedEntry({
126128
setPath,
127129
srLabel,
128130
}: {
129-
exercise: ExerciseWithoutDefaults;
131+
exercise: ExerciseEntryData;
130132
locale: Locale;
131133
setPath: string;
132134
srLabel: string;

packages/backend/convex/_generated/api.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,8 @@ import type * as exercises_answerScoring_spec from "../exercises/answerScoring/s
170170
import type * as exercises_helpers from "../exercises/helpers.js";
171171
import type * as exercises_mutations from "../exercises/mutations.js";
172172
import type * as exercises_queries from "../exercises/queries.js";
173+
import type * as exercises_renderable_impl from "../exercises/renderable/impl.js";
174+
import type * as exercises_renderable_spec from "../exercises/renderable/spec.js";
173175
import type * as exercises_utils from "../exercises/utils.js";
174176
import type * as functions from "../functions.js";
175177
import type * as http from "../http.js";
@@ -508,6 +510,8 @@ declare const fullApi: ApiFromModules<{
508510
"exercises/helpers": typeof exercises_helpers;
509511
"exercises/mutations": typeof exercises_mutations;
510512
"exercises/queries": typeof exercises_queries;
513+
"exercises/renderable/impl": typeof exercises_renderable_impl;
514+
"exercises/renderable/spec": typeof exercises_renderable_spec;
511515
"exercises/utils": typeof exercises_utils;
512516
functions: typeof functions;
513517
http: typeof http;
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { api } from "@repo/backend/convex/_generated/api";
2+
import type { MutationCtx } from "@repo/backend/convex/_generated/server";
3+
import { CONTENT_SYNC_BATCH_LIMITS } from "@repo/backend/convex/contentSync/constants";
4+
import { exerciseSetIntegrityErrorCode } from "@repo/backend/convex/exercises/renderable/spec";
5+
import { SUPPORTED_CONTENT_LOCALES } from "@repo/backend/convex/lib/validators/contents";
6+
import schema from "@repo/backend/convex/schema";
7+
import { convexModules } from "@repo/backend/convex/test.setup";
8+
import { convexTest } from "convex-test";
9+
import { describe, expect, it } from "vitest";
10+
11+
const NOW = Date.parse("2026-01-01T00:00:00.000Z");
12+
const exerciseSetSlug =
13+
"exercises/high-school/snbt/quantitative-knowledge/try-out/2026/set-1";
14+
const testLocale = SUPPORTED_CONTENT_LOCALES[1];
15+
16+
/**
17+
* Inserts one synced exercise set for query tests.
18+
*/
19+
async function insertExerciseSet(ctx: MutationCtx, questionCount: number) {
20+
return await ctx.db.insert("exerciseSets", {
21+
category: "high-school",
22+
exerciseType: "try-out",
23+
locale: testLocale,
24+
material: "quantitative-knowledge",
25+
questionCount,
26+
setName: "set-1",
27+
slug: exerciseSetSlug,
28+
syncedAt: NOW,
29+
title: "Set 1",
30+
type: "snbt",
31+
});
32+
}
33+
34+
/**
35+
* Inserts one synced exercise question under a set.
36+
*/
37+
async function insertExerciseQuestion(
38+
ctx: MutationCtx,
39+
setId: Awaited<ReturnType<typeof insertExerciseSet>>,
40+
number: number
41+
) {
42+
return await ctx.db.insert("exerciseQuestions", {
43+
answerBody: `Answer ${number}`,
44+
category: "high-school",
45+
contentHash: `hash-${number}`,
46+
date: NOW,
47+
exerciseType: "try-out",
48+
locale: testLocale,
49+
material: "quantitative-knowledge",
50+
number,
51+
questionBody: `Question ${number}`,
52+
setId,
53+
setName: "set-1",
54+
slug: `${exerciseSetSlug}/${number}`,
55+
syncedAt: NOW,
56+
title: `Question ${number}`,
57+
type: "snbt",
58+
});
59+
}
60+
61+
/**
62+
* Inserts one synced choice row under a question.
63+
*/
64+
async function insertExerciseChoice(
65+
ctx: MutationCtx,
66+
questionId: Awaited<ReturnType<typeof insertExerciseQuestion>>,
67+
order: number
68+
) {
69+
await ctx.db.insert("exerciseChoices", {
70+
isCorrect: order === 0,
71+
label: `Choice ${order + 1}`,
72+
locale: testLocale,
73+
optionKey: String.fromCharCode(65 + order),
74+
order,
75+
questionId,
76+
});
77+
}
78+
79+
/**
80+
* Loads renderable rows for the shared fixture set.
81+
*/
82+
function queryRenderableRows(t: ReturnType<typeof convexTest>) {
83+
return t.query(api.exercises.queries.getRenderableRowsBySlug, {
84+
locale: testLocale,
85+
slug: exerciseSetSlug,
86+
});
87+
}
88+
89+
/**
90+
* Asserts one structured synced-content integrity failure.
91+
*/
92+
async function expectIntegrityError(
93+
promise: ReturnType<typeof queryRenderableRows>,
94+
message: string
95+
) {
96+
await expect(promise).rejects.toMatchObject({
97+
data: {
98+
code: exerciseSetIntegrityErrorCode,
99+
message,
100+
},
101+
});
102+
}
103+
104+
describe("exercises/queries", () => {
105+
it("loads renderable exercise rows from indexed synced content rows", async () => {
106+
const t = convexTest(schema, convexModules);
107+
108+
await t.mutation(async (ctx) => {
109+
const setId = await insertExerciseSet(ctx, 2);
110+
const firstQuestionId = await insertExerciseQuestion(ctx, setId, 1);
111+
const secondQuestionId = await insertExerciseQuestion(ctx, setId, 2);
112+
113+
await insertExerciseChoice(ctx, firstQuestionId, 1);
114+
await insertExerciseChoice(ctx, firstQuestionId, 0);
115+
await insertExerciseChoice(ctx, secondQuestionId, 0);
116+
});
117+
118+
const result = await queryRenderableRows(t);
119+
120+
expect(result).toEqual([
121+
{
122+
choices: {
123+
id: [
124+
{ label: "Choice 1", value: true },
125+
{ label: "Choice 2", value: false },
126+
],
127+
en: [],
128+
},
129+
number: 1,
130+
},
131+
{
132+
choices: {
133+
id: [{ label: "Choice 1", value: true }],
134+
en: [],
135+
},
136+
number: 2,
137+
},
138+
]);
139+
});
140+
141+
it("returns null when the synced exercise set does not exist", async () => {
142+
const t = convexTest(schema, convexModules);
143+
144+
const result = await queryRenderableRows(t);
145+
146+
expect(result).toBeNull();
147+
});
148+
149+
it("fails when question rows do not match the declared count", async () => {
150+
const t = convexTest(schema, convexModules);
151+
152+
await t.mutation(async (ctx) => {
153+
await insertExerciseSet(ctx, 2);
154+
});
155+
156+
await expectIntegrityError(
157+
queryRenderableRows(t),
158+
"Exercise set question count does not match synced question rows."
159+
);
160+
});
161+
162+
it("fails when question numbers are not contiguous", async () => {
163+
const t = convexTest(schema, convexModules);
164+
165+
await t.mutation(async (ctx) => {
166+
const setId = await insertExerciseSet(ctx, 2);
167+
const firstQuestionId = await insertExerciseQuestion(ctx, setId, 1);
168+
const thirdQuestionId = await insertExerciseQuestion(ctx, setId, 3);
169+
170+
await insertExerciseChoice(ctx, firstQuestionId, 0);
171+
await insertExerciseChoice(ctx, thirdQuestionId, 0);
172+
});
173+
174+
await expectIntegrityError(
175+
queryRenderableRows(t),
176+
"Exercise set questions must use contiguous 1-based numbers."
177+
);
178+
});
179+
180+
it("fails when a question has more choices than content sync allows", async () => {
181+
const t = convexTest(schema, convexModules);
182+
183+
await t.mutation(async (ctx) => {
184+
const setId = await insertExerciseSet(ctx, 1);
185+
const questionId = await insertExerciseQuestion(ctx, setId, 1);
186+
187+
for (
188+
let order = 0;
189+
order <= CONTENT_SYNC_BATCH_LIMITS.exerciseChoices;
190+
order++
191+
) {
192+
await insertExerciseChoice(ctx, questionId, order);
193+
}
194+
});
195+
196+
await expectIntegrityError(
197+
queryRenderableRows(t),
198+
"Exercise question has more synced choices than the content-sync choice limit."
199+
);
200+
});
201+
});

0 commit comments

Comments
 (0)