rogasper commited on
Commit
92a1d83
·
1 Parent(s): f2d1fea

feat: enhance testing capabilities and introduce new components for question management. Add testing section in AGENTS.md with instructions for running tests and configuring the test environment. Update package.json files to include test scripts and integrate @electric-sql/pglite for in-memory database testing. Introduce CalloutCard and update FilterBar and SoalBrowser components to support visibility filters for private and public questions. Implement new test cases for various functionalities across the application.

Browse files
Files changed (40) hide show
  1. AGENTS.md +39 -1
  2. apps/web/package.json +1 -0
  3. apps/web/src/components/bank/CalloutCard.tsx +48 -0
  4. apps/web/src/components/bank/FilterBar.tsx +46 -0
  5. apps/web/src/components/bank/QuestionCard.tsx +9 -3
  6. apps/web/src/components/bank/SoalBrowser.tsx +32 -0
  7. apps/web/src/lib/__tests__/avatar-url.test.ts +95 -0
  8. apps/web/src/lib/__tests__/difficulty-mapping.test.ts +42 -0
  9. apps/web/src/lib/__tests__/exam-constants.test.ts +68 -0
  10. apps/web/src/lib/__tests__/format.test.ts +21 -0
  11. apps/web/src/lib/__tests__/generate-constants.test.ts +91 -0
  12. apps/web/src/lib/__tests__/time.test.ts +34 -0
  13. apps/web/src/routes/bank.tsx +28 -5
  14. apps/web/src/routes/history.tsx +68 -1
  15. bun.lock +3 -0
  16. package.json +1 -0
  17. packages/ai/package.json +3 -0
  18. packages/ai/src/__tests__/client.test.ts +212 -0
  19. packages/ai/src/__tests__/errors.test.ts +24 -0
  20. packages/ai/src/__tests__/prompts.test.ts +122 -0
  21. packages/ai/src/__tests__/repair.test.ts +218 -0
  22. packages/ai/src/__tests__/schema-to-prompt.test.ts +67 -0
  23. packages/ai/src/__tests__/schemas.test.ts +265 -0
  24. packages/ai/src/client.ts +1 -1
  25. packages/ai/src/schema-to-prompt.ts +3 -10
  26. packages/api/package.json +4 -1
  27. packages/api/src/__tests__/attempt.integration.test.ts +207 -0
  28. packages/api/src/__tests__/package.integration.test.ts +229 -0
  29. packages/api/src/__tests__/question.integration.test.ts +96 -0
  30. packages/api/src/__tests__/queue.test.ts +228 -0
  31. packages/api/src/__tests__/test-db.ts +65 -0
  32. packages/api/src/__tests__/test-setup.ts +84 -0
  33. packages/api/src/lib/__tests__/encryption.test.ts +54 -0
  34. packages/api/src/lib/__tests__/errors.test.ts +71 -0
  35. packages/api/src/lib/__tests__/ownership.test.ts +63 -0
  36. packages/api/src/lib/__tests__/pagination.test.ts +52 -0
  37. packages/api/src/lib/__tests__/visibility.test.ts +20 -0
  38. packages/api/src/queue.ts +5 -5
  39. packages/api/src/routers/attempt.ts +10 -1
  40. packages/api/src/routers/question.ts +12 -6
AGENTS.md CHANGED
@@ -75,6 +75,10 @@ bun run db:generate # Generate migration files
75
  bun run db:start # Start local DB (if configured)
76
  bun run db:stop # Stop local DB
77
 
 
 
 
 
78
  # Build
79
  bun run build # Build all packages
80
  ```
@@ -192,4 +196,38 @@ bun run build # Build all packages
192
 
193
  ---
194
 
195
- _Last updated: 2026-05-06_
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  bun run db:start # Start local DB (if configured)
76
  bun run db:stop # Stop local DB
77
 
78
+ # Testing
79
+ bun test # Run all tests
80
+ bun run turbo test # Run via turbo pipeline
81
+
82
  # Build
83
  bun run build # Build all packages
84
  ```
 
196
 
197
  ---
198
 
199
+ ## 12. Testing
200
+
201
+ ### Test Runner
202
+ - **Bun test** (`bun test`) — built-in, Jest/Vitest compatible API (`describe`, `it`, `expect`).
203
+ - Turbo task `test` sudah dikonfigurasi.
204
+
205
+ ### Test Location
206
+ - Unit tests: `src/__tests__/*.test.ts` di masing-masing package.
207
+ - Integration tests: `packages/api/src/__tests__/*.integration.test.ts` (pakai PGlite in-memory DB).
208
+
209
+ ### Integration DB (PGlite)
210
+ - **PGlite** (`@electric-sql/pglite`) — PostgreSQL WASM in-memory.
211
+ - Strategy: `mock.module("@labas/db")` intercept DB saat dynamic import untuk inject PGlite-based drizzle instance.
212
+ - Setup helper: `packages/api/src/__tests__/test-setup.ts` — skema 14 tabel via raw SQL + seed data.
213
+ - Schema diimport dari `../../../db/src/schema` (langsung, bukan via `@labas/db`) untuk hindari env validation side-effect.
214
+ - Env vars di-mock via `mock.module("@labas/env/server")` sebelum dynamic import.
215
+
216
+ ### tRPC Router Testing
217
+ - Gunakan `router.createCaller({ session, auth })` untuk memanggil procedure.
218
+ - Protected procedure: `session: { user: { id }, expiresAt: new Date() }`.
219
+ - Public procedure: `session: null`.
220
+
221
+ ### Known Limitations
222
+ - **Rate limiter** (`checkRateLimit` di `attempt.ts`) pakai in-memory `Map`. Gunakan user ID berbeda per test atau `Bun.sleep()` untuk menghindari rate limit blocker.
223
+ - **Timer validation** (`finish`) butuh ≥5 detik elapsed sejak `start` — pakai `Bun.sleep()` + `{ timeout: 30000 }` pada `it()`.
224
+ - **Env vars Wajib** untuk di-mock saat integration test: `DATABASE_URL`, `BETTER_AUTH_SECRET` (≥32 chars), `BETTER_AUTH_URL`, `CORS_ORIGIN`, `API_KEY_ENCRYPTION_KEY` (≥32 chars), `REDIS_URL`, `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`.
225
+
226
+ ### TDD Convention
227
+ - TDD: tulis test dulu, lihat fail, baru implementasi.
228
+ - Untuk existing code yang belum ada test: tulis test yang verifikasi behavior saat ini.
229
+ - Fungsi internal yang perlu di-test secara unit harus di-`export`.
230
+
231
+ ---
232
+
233
+ _Last updated: 2026-05-14_
apps/web/package.json CHANGED
@@ -9,6 +9,7 @@
9
  "build": "vite build",
10
  "serve": "vite preview",
11
  "start": "vite",
 
12
  "check-types": "vite build && tsc --noEmit",
13
  "generate-pwa-assets": "pwa-assets-generator"
14
  },
 
9
  "build": "vite build",
10
  "serve": "vite preview",
11
  "start": "vite",
12
+ "test": "bun test",
13
  "check-types": "vite build && tsc --noEmit",
14
  "generate-pwa-assets": "pwa-assets-generator"
15
  },
apps/web/src/components/bank/CalloutCard.tsx ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Button } from "@labas/ui/components/button";
2
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
3
+
4
+ interface CalloutCardProps {
5
+ privateCount: number;
6
+ onPublishAll: () => void;
7
+ onDismiss: () => void;
8
+ }
9
+
10
+ export function CalloutCard({ privateCount, onPublishAll, onDismiss }: CalloutCardProps) {
11
+ if (privateCount <= 0) return null;
12
+
13
+ return (
14
+ <div className="md:col-span-2 mb-4">
15
+ <div className="flex items-start gap-3 p-4 rounded-[var(--radius-xl)] bg-[var(--slushie-500)]/10 border-l-4 border-[var(--slushie-500)] border-2 border-[var(--slushie-500)]/20">
16
+ <div className="shrink-0 mt-0.5">
17
+ <div className="w-8 h-8 rounded-full bg-[var(--slushie-500)]/20 flex items-center justify-center">
18
+ <MaterialIcon name="info" className="text-sm text-[var(--slushie-800)]" />
19
+ </div>
20
+ </div>
21
+ <div className="flex-1 min-w-0">
22
+ <p className="text-sm font-semibold text-[var(--clay-black)]">
23
+ Kamu punya <span className="text-[var(--slushie-800)]">{privateCount} soal privat</span>
24
+ </p>
25
+ <p className="text-xs text-[var(--warm-charcoal)] mt-1 leading-relaxed">
26
+ Soal privat hanya bisa kamu lihat. Jadikan publik agar soal bisa muncul di daftar paket soal dan diakses pengguna lain.
27
+ </p>
28
+ <div className="flex items-center gap-2 mt-3">
29
+ <Button
30
+ size="sm"
31
+ onClick={onPublishAll}
32
+ className="rounded-[var(--radius-lg)] bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] text-xs cursor-pointer"
33
+ >
34
+ <MaterialIcon name="public" className="text-xs mr-1" />
35
+ Jadikan Semua Publik
36
+ </Button>
37
+ <button
38
+ onClick={onDismiss}
39
+ className="text-xs text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors cursor-pointer font-medium"
40
+ >
41
+ Tutup
42
+ </button>
43
+ </div>
44
+ </div>
45
+ </div>
46
+ </div>
47
+ );
48
+ }
apps/web/src/components/bank/FilterBar.tsx CHANGED
@@ -16,6 +16,7 @@ interface FilterBarProps {
16
  tab: "mine" | "public";
17
  searchText: string;
18
  examType: string;
 
19
  activeChips: FilterChip[];
20
  hasFilters: boolean;
21
  isAdvancedOpen: boolean;
@@ -26,6 +27,7 @@ interface FilterBarProps {
26
  onSetTab: (tab: "mine" | "public") => void;
27
  onSetSearch: (value: string) => void;
28
  onSetExamType: (value: string) => void;
 
29
  onClearFilters: () => void;
30
  onOpenMobileSheet: () => void;
31
  advancedFilters: React.ReactNode;
@@ -36,6 +38,7 @@ export function FilterBar({
36
  tab,
37
  searchText,
38
  examType,
 
39
  activeChips,
40
  hasFilters,
41
  isAdvancedOpen,
@@ -46,6 +49,7 @@ export function FilterBar({
46
  onSetTab,
47
  onSetSearch,
48
  onSetExamType,
 
49
  onClearFilters,
50
  onOpenMobileSheet,
51
  advancedFilters,
@@ -104,6 +108,33 @@ export function FilterBar({
104
  })}
105
  </div>
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  {/* ── Tier 3: Search + Advanced filter toggle ── */}
108
  <div className="flex flex-col md:flex-row gap-3">
109
  <div className="relative flex-1">
@@ -241,6 +272,21 @@ function TabButton({ active, onClick, children }: { active: boolean; onClick: ()
241
  );
242
  }
243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  function ChipButton({ active, onClick, children, disabled }: { active: boolean; onClick: () => void; children: React.ReactNode; disabled?: boolean }) {
245
  return (
246
  <button
 
16
  tab: "mine" | "public";
17
  searchText: string;
18
  examType: string;
19
+ visibility?: "all" | "private" | "public";
20
  activeChips: FilterChip[];
21
  hasFilters: boolean;
22
  isAdvancedOpen: boolean;
 
27
  onSetTab: (tab: "mine" | "public") => void;
28
  onSetSearch: (value: string) => void;
29
  onSetExamType: (value: string) => void;
30
+ onSetVisibility?: (value: "all" | "private" | "public") => void;
31
  onClearFilters: () => void;
32
  onOpenMobileSheet: () => void;
33
  advancedFilters: React.ReactNode;
 
38
  tab,
39
  searchText,
40
  examType,
41
+ visibility = "all",
42
  activeChips,
43
  hasFilters,
44
  isAdvancedOpen,
 
49
  onSetTab,
50
  onSetSearch,
51
  onSetExamType,
52
+ onSetVisibility,
53
  onClearFilters,
54
  onOpenMobileSheet,
55
  advancedFilters,
 
108
  })}
109
  </div>
110
 
111
+ {/* ── Visibility sub-filter (only in "mine" tab) ── */}
112
+ {mode === "soal" && tab === "mine" && onSetVisibility && (
113
+ <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
114
+ <VisChipButton
115
+ active={visibility === "all"}
116
+ onClick={() => onSetVisibility("all")}
117
+ >
118
+ <MaterialIcon name="visibility" className="text-xs" />
119
+ Semua
120
+ </VisChipButton>
121
+ <VisChipButton
122
+ active={visibility === "private"}
123
+ onClick={() => onSetVisibility("private")}
124
+ >
125
+ <MaterialIcon name="lock" className="text-xs" />
126
+ Privat
127
+ </VisChipButton>
128
+ <VisChipButton
129
+ active={visibility === "public"}
130
+ onClick={() => onSetVisibility("public")}
131
+ >
132
+ <MaterialIcon name="public" className="text-xs" />
133
+ Publik
134
+ </VisChipButton>
135
+ </div>
136
+ )}
137
+
138
  {/* ── Tier 3: Search + Advanced filter toggle ── */}
139
  <div className="flex flex-col md:flex-row gap-3">
140
  <div className="relative flex-1">
 
272
  );
273
  }
274
 
275
+ function VisChipButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
276
+ return (
277
+ <button
278
+ onClick={onClick}
279
+ className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all flex items-center gap-1 cursor-pointer ${
280
+ active
281
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
282
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
283
+ }`}
284
+ >
285
+ {children}
286
+ </button>
287
+ );
288
+ }
289
+
290
  function ChipButton({ active, onClick, children, disabled }: { active: boolean; onClick: () => void; children: React.ReactNode; disabled?: boolean }) {
291
  return (
292
  <button
apps/web/src/components/bank/QuestionCard.tsx CHANGED
@@ -29,6 +29,8 @@ export function QuestionCard({
29
  onTogglePublic,
30
  onDelete,
31
  }: QuestionCardProps) {
 
 
32
  return (
33
  <div
34
  onClick={() => { if (!disabled) onOpenDetail(); }}
@@ -39,7 +41,9 @@ export function QuestionCard({
39
  ? "bg-[var(--matcha-100)] border-[var(--matcha-600)] clay-hover cursor-pointer ring-2 ring-[var(--matcha-400)]"
40
  : isInBundle
41
  ? "bg-[var(--matcha-100)] border-[var(--clay-black)] clay-shadow clay-hover cursor-pointer"
42
- : "bg-[var(--pure-white)] border-[var(--oat-border)] clay-shadow clay-hover cursor-pointer"
 
 
43
  }`}
44
  >
45
  <div className="p-5 flex flex-col h-full">
@@ -112,12 +116,14 @@ export function QuestionCard({
112
  <div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
113
  <button
114
  onClick={onTogglePublic}
115
- className={`text-xs font-semibold px-3 py-1.5 rounded-full transition-colors cursor-pointer ${
 
116
  q.isPublic
117
  ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
118
- : "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
119
  }`}
120
  >
 
121
  {q.isPublic ? "Publik" : "Privat"}
122
  </button>
123
  <button
 
29
  onTogglePublic,
30
  onDelete,
31
  }: QuestionCardProps) {
32
+ const isPrivate = isOwner && !q.isPublic;
33
+
34
  return (
35
  <div
36
  onClick={() => { if (!disabled) onOpenDetail(); }}
 
41
  ? "bg-[var(--matcha-100)] border-[var(--matcha-600)] clay-hover cursor-pointer ring-2 ring-[var(--matcha-400)]"
42
  : isInBundle
43
  ? "bg-[var(--matcha-100)] border-[var(--clay-black)] clay-shadow clay-hover cursor-pointer"
44
+ : isPrivate
45
+ ? "bg-[var(--pure-white)] border-[var(--oat-border)] border-l-[var(--warm-charcoal)] border-l-4 clay-shadow clay-hover cursor-pointer"
46
+ : "bg-[var(--pure-white)] border-[var(--oat-border)] clay-shadow clay-hover cursor-pointer"
47
  }`}
48
  >
49
  <div className="p-5 flex flex-col h-full">
 
116
  <div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
117
  <button
118
  onClick={onTogglePublic}
119
+ title={q.isPublic ? "Klik untuk jadikan privat" : "Klik untuk jadikan publik"}
120
+ className={`text-xs font-semibold px-3 py-1.5 rounded-full transition-colors cursor-pointer flex items-center gap-1 ${
121
  q.isPublic
122
  ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
123
+ : "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)]"
124
  }`}
125
  >
126
+ {!q.isPublic && <MaterialIcon name="lock" className="text-xs" />}
127
  {q.isPublic ? "Publik" : "Privat"}
128
  </button>
129
  <button
apps/web/src/components/bank/SoalBrowser.tsx CHANGED
@@ -3,6 +3,7 @@ import { Card } from "@labas/ui/components/card";
3
  import { Button } from "@labas/ui/components/button";
4
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
5
  import { QuestionCard } from "./QuestionCard";
 
6
 
7
  interface SoalBrowserProps {
8
  isLoading: boolean;
@@ -22,6 +23,7 @@ interface SoalBrowserProps {
22
  onLoadMore: () => void;
23
  onClearFilters: () => void;
24
  onBulkPublish?: (ids: string[]) => void;
 
25
  }
26
 
27
  export function SoalBrowser({
@@ -42,6 +44,7 @@ export function SoalBrowser({
42
  onLoadMore,
43
  onClearFilters,
44
  onBulkPublish,
 
45
  }: SoalBrowserProps) {
46
  const sentinelRef = useRef<HTMLDivElement>(null);
47
  const isLocked = (q: any) => !!lockedExamType && q.examTypeId !== lockedExamType;
@@ -55,6 +58,27 @@ export function SoalBrowser({
55
  setSelectedIds(new Set());
56
  }, [filterKey]);
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  useEffect(() => {
59
  const el = sentinelRef.current;
60
  if (!el || !hasMore || isFetchingNextPage) return;
@@ -183,6 +207,14 @@ export function SoalBrowser({
183
  {renderBulkToolbar()}
184
  <div className="md:col-span-2" />
185
 
 
 
 
 
 
 
 
 
186
  {questions[0] && (
187
  <div className="md:col-span-2">
188
  <QuestionCard
 
3
  import { Button } from "@labas/ui/components/button";
4
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
5
  import { QuestionCard } from "./QuestionCard";
6
+ import { CalloutCard } from "./CalloutCard";
7
 
8
  interface SoalBrowserProps {
9
  isLoading: boolean;
 
23
  onLoadMore: () => void;
24
  onClearFilters: () => void;
25
  onBulkPublish?: (ids: string[]) => void;
26
+ onPublishAllPrivate?: (ids: string[]) => void;
27
  }
28
 
29
  export function SoalBrowser({
 
44
  onLoadMore,
45
  onClearFilters,
46
  onBulkPublish,
47
+ onPublishAllPrivate,
48
  }: SoalBrowserProps) {
49
  const sentinelRef = useRef<HTMLDivElement>(null);
50
  const isLocked = (q: any) => !!lockedExamType && q.examTypeId !== lockedExamType;
 
58
  setSelectedIds(new Set());
59
  }, [filterKey]);
60
 
61
+ // ── Private callout dismiss ──
62
+ const calloutDismissed = typeof window !== "undefined"
63
+ ? localStorage.getItem("labas-bank-private-callout-dismissed") === "true"
64
+ : false;
65
+
66
+ const privateQuestions = questions.filter(
67
+ (q: any) => !q.isPublic && q.creatorUserId === userId,
68
+ );
69
+
70
+ const handleDismissCallout = () => {
71
+ localStorage.setItem("labas-bank-private-callout-dismissed", "true");
72
+ // Force re-render by toggling a state
73
+ setBulkMode((prev) => prev);
74
+ };
75
+
76
+ const handlePublishAllPrivate = () => {
77
+ if (onPublishAllPrivate && privateQuestions.length > 0) {
78
+ onPublishAllPrivate(privateQuestions.map((q: any) => q.id));
79
+ }
80
+ };
81
+
82
  useEffect(() => {
83
  const el = sentinelRef.current;
84
  if (!el || !hasMore || isFetchingNextPage) return;
 
207
  {renderBulkToolbar()}
208
  <div className="md:col-span-2" />
209
 
210
+ {tab === "mine" && privateQuestions.length > 0 && !calloutDismissed && (
211
+ <CalloutCard
212
+ privateCount={privateQuestions.length}
213
+ onPublishAll={handlePublishAllPrivate}
214
+ onDismiss={handleDismissCallout}
215
+ />
216
+ )}
217
+
218
  {questions[0] && (
219
  <div className="md:col-span-2">
220
  <QuestionCard
apps/web/src/lib/__tests__/avatar-url.test.ts ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ buildDiceBearLoreleiUrl,
4
+ BACKGROUND_COLORS,
5
+ SKIN_COLORS,
6
+ HAIR_COLORS,
7
+ } from "../avatar-url";
8
+
9
+ describe("buildDiceBearLoreleiUrl", () => {
10
+ it("builds URL with required seed", () => {
11
+ const url = buildDiceBearLoreleiUrl({ seed: "test123" });
12
+ expect(url).toContain("seed=test123");
13
+ expect(url).toContain("backgroundType=solid");
14
+ });
15
+
16
+ it("includes optional backgroundColor", () => {
17
+ const url = buildDiceBearLoreleiUrl({ seed: "abc", backgroundColor: "84e7a5" });
18
+ expect(url).toContain("backgroundColor=84e7a5");
19
+ });
20
+
21
+ it("includes optional hairColor", () => {
22
+ const url = buildDiceBearLoreleiUrl({ seed: "abc", hairColor: "000000" });
23
+ expect(url).toContain("hairColor=000000");
24
+ });
25
+
26
+ it("includes optional skinColor", () => {
27
+ const url = buildDiceBearLoreleiUrl({ seed: "abc", skinColor: "ffe4c4" });
28
+ expect(url).toContain("skinColor=ffe4c4");
29
+ });
30
+
31
+ it("sets glassesProbability when glasses is true", () => {
32
+ const url = buildDiceBearLoreleiUrl({ seed: "abc", glasses: true });
33
+ expect(url).toContain("glassesProbability=100");
34
+ });
35
+
36
+ it("sets frecklesProbability when freckles is true", () => {
37
+ const url = buildDiceBearLoreleiUrl({ seed: "abc", freckles: true });
38
+ expect(url).toContain("frecklesProbability=100");
39
+ });
40
+
41
+ it("sets beardProbability when beard is true", () => {
42
+ const url = buildDiceBearLoreleiUrl({ seed: "abc", beard: true });
43
+ expect(url).toContain("beardProbability=100");
44
+ });
45
+
46
+ it("sets earringsProbability when earrings is true", () => {
47
+ const url = buildDiceBearLoreleiUrl({ seed: "abc", earrings: true });
48
+ expect(url).toContain("earringsProbability=100");
49
+ });
50
+
51
+ it("uses DiceBear API base URL", () => {
52
+ const url = buildDiceBearLoreleiUrl({ seed: "x" });
53
+ expect(url).toStartWith("https://api.dicebear.com/9.x/lorelei/svg?");
54
+ });
55
+
56
+ it("combines multiple options", () => {
57
+ const url = buildDiceBearLoreleiUrl({
58
+ seed: "multi",
59
+ backgroundColor: "3bd3fd",
60
+ hairColor: "8b5e3c",
61
+ skinColor: "c6866b",
62
+ glasses: true,
63
+ });
64
+ expect(url).toContain("seed=multi");
65
+ expect(url).toContain("backgroundColor=3bd3fd");
66
+ expect(url).toContain("hairColor=8b5e3c");
67
+ expect(url).toContain("skinColor=c6866b");
68
+ expect(url).toContain("glassesProbability=100");
69
+ });
70
+ });
71
+
72
+ describe("BACKGROUND_COLORS", () => {
73
+ it("has 8 color options", () => {
74
+ expect(BACKGROUND_COLORS).toHaveLength(8);
75
+ });
76
+
77
+ it("each option has label and value", () => {
78
+ for (const c of BACKGROUND_COLORS) {
79
+ expect(c).toHaveProperty("label");
80
+ expect(c).toHaveProperty("value");
81
+ }
82
+ });
83
+ });
84
+
85
+ describe("SKIN_COLORS", () => {
86
+ it("has 5 skin tone options", () => {
87
+ expect(SKIN_COLORS).toHaveLength(5);
88
+ });
89
+ });
90
+
91
+ describe("HAIR_COLORS", () => {
92
+ it("has 7 hair color options", () => {
93
+ expect(HAIR_COLORS).toHaveLength(7);
94
+ });
95
+ });
apps/web/src/lib/__tests__/difficulty-mapping.test.ts ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "bun:test";
2
+ import { DIFFICULTY_LABELS, getDifficultyLabel } from "../difficulty-mapping";
3
+
4
+ describe("DIFFICULTY_LABELS", () => {
5
+ it("has entries for all 8 exam types", () => {
6
+ const expected = ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE", "TOPIK", "TOAFL", "DELE"];
7
+ expect(Object.keys(DIFFICULTY_LABELS)).toEqual(expected);
8
+ });
9
+
10
+ it("each exam has exactly 5 difficulty levels", () => {
11
+ for (const [exam, labels] of Object.entries(DIFFICULTY_LABELS)) {
12
+ expect(labels).toHaveLength(5);
13
+ }
14
+ });
15
+ });
16
+
17
+ describe("getDifficultyLabel", () => {
18
+ it("returns correct label for IELTS level 1", () => {
19
+ expect(getDifficultyLabel("IELTS", 1)).toBe("Band 4.0");
20
+ });
21
+
22
+ it("returns correct label for IELTS level 5", () => {
23
+ expect(getDifficultyLabel("IELTS", 5)).toBe("Band 8.0+");
24
+ });
25
+
26
+ it("returns correct label for JLPT level 3", () => {
27
+ expect(getDifficultyLabel("JLPT", 3)).toBe("N3");
28
+ });
29
+
30
+ it("clamps level below 1", () => {
31
+ expect(getDifficultyLabel("IELTS", 0)).toBe("Band 4.0");
32
+ });
33
+
34
+ it("clamps level above 5", () => {
35
+ expect(getDifficultyLabel("IELTS", 99)).toBe("Band 8.0+");
36
+ });
37
+
38
+ it("returns fallback for unknown exam types", () => {
39
+ expect(getDifficultyLabel("UNKNOWN", 3)).toBe("Level 3");
40
+ expect(getDifficultyLabel("GRE", 1)).toBe("Level 1");
41
+ });
42
+ });
apps/web/src/lib/__tests__/exam-constants.test.ts ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "bun:test";
2
+ import { EXAM_TYPES, SECTIONS, FORMATS, DIFFICULTIES } from "../exam-constants";
3
+
4
+ describe("EXAM_TYPES", () => {
5
+ it("has 8 exam types", () => {
6
+ expect(EXAM_TYPES).toHaveLength(8);
7
+ });
8
+
9
+ it("each entry has id and name", () => {
10
+ for (const exam of EXAM_TYPES) {
11
+ expect(exam).toHaveProperty("id");
12
+ expect(exam).toHaveProperty("name");
13
+ expect(typeof exam.id).toBe("string");
14
+ expect(typeof exam.name).toBe("string");
15
+ }
16
+ });
17
+
18
+ it("includes all expected exams", () => {
19
+ const ids = EXAM_TYPES.map((e) => e.id);
20
+ expect(ids).toContain("IELTS");
21
+ expect(ids).toContain("TOEFL");
22
+ expect(ids).toContain("JLPT");
23
+ expect(ids).toContain("HSK");
24
+ expect(ids).toContain("GOETHE");
25
+ expect(ids).toContain("TOPIK");
26
+ expect(ids).toContain("TOAFL");
27
+ expect(ids).toContain("DELE");
28
+ });
29
+
30
+ it("has no duplicate IDs", () => {
31
+ const ids = EXAM_TYPES.map((e) => e.id);
32
+ expect(new Set(ids).size).toBe(ids.length);
33
+ });
34
+ });
35
+
36
+ describe("SECTIONS", () => {
37
+ it("has 2 sections", () => {
38
+ expect(SECTIONS).toHaveLength(2);
39
+ });
40
+
41
+ it("includes READING and WRITING", () => {
42
+ const ids = SECTIONS.map((s) => s.id);
43
+ expect(ids).toContain("READING");
44
+ expect(ids).toContain("WRITING");
45
+ });
46
+ });
47
+
48
+ describe("FORMATS", () => {
49
+ it("has 20 question formats", () => {
50
+ expect(FORMATS).toHaveLength(20);
51
+ });
52
+
53
+ it("has no duplicates", () => {
54
+ expect(new Set(FORMATS).size).toBe(FORMATS.length);
55
+ });
56
+ });
57
+
58
+ describe("DIFFICULTIES", () => {
59
+ it("has 5 levels", () => {
60
+ expect(DIFFICULTIES).toHaveLength(5);
61
+ });
62
+
63
+ it("has values 1-5", () => {
64
+ DIFFICULTIES.forEach((d, i) => {
65
+ expect(d.value).toBe(i + 1);
66
+ });
67
+ });
68
+ });
apps/web/src/lib/__tests__/format.test.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "bun:test";
2
+ import { formatLabel } from "../format";
3
+
4
+ describe("formatLabel", () => {
5
+ it("converts snake_case to Title Case", () => {
6
+ expect(formatLabel("multiple_choice")).toBe("Multiple Choice");
7
+ expect(formatLabel("true_false_not_given")).toBe("True False Not Given");
8
+ });
9
+
10
+ it("handles single word", () => {
11
+ expect(formatLabel("cloze")).toBe("Cloze");
12
+ });
13
+
14
+ it("handles empty string", () => {
15
+ expect(formatLabel("")).toBe("");
16
+ });
17
+
18
+ it("handles already spaced string", () => {
19
+ expect(formatLabel("multiple choice")).toBe("Multiple Choice");
20
+ });
21
+ });
apps/web/src/lib/__tests__/generate-constants.test.ts ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ EXAM_TYPES,
4
+ SECTIONS,
5
+ FORMATS,
6
+ TOPICS,
7
+ DIFFICULTIES,
8
+ QUESTION_COUNT_PRESETS,
9
+ } from "../generate-constants";
10
+
11
+ describe("EXAM_TYPES", () => {
12
+ it("has 8 exam types", () => {
13
+ expect(EXAM_TYPES).toHaveLength(8);
14
+ });
15
+
16
+ it("each entry has id, name, and code", () => {
17
+ for (const exam of EXAM_TYPES) {
18
+ expect(exam).toHaveProperty("id");
19
+ expect(exam).toHaveProperty("name");
20
+ expect(exam).toHaveProperty("code");
21
+ }
22
+ });
23
+
24
+ it("has no duplicate IDs", () => {
25
+ const ids = EXAM_TYPES.map((e) => e.id);
26
+ expect(new Set(ids).size).toBe(ids.length);
27
+ });
28
+ });
29
+
30
+ describe("SECTIONS", () => {
31
+ it("has 2 sections with id, name, and icon", () => {
32
+ expect(SECTIONS).toHaveLength(2);
33
+ for (const s of SECTIONS) {
34
+ expect(s).toHaveProperty("id");
35
+ expect(s).toHaveProperty("name");
36
+ expect(s).toHaveProperty("icon");
37
+ }
38
+ });
39
+ });
40
+
41
+ describe("FORMATS", () => {
42
+ it("has 20 question formats", () => {
43
+ expect(FORMATS).toHaveLength(20);
44
+ });
45
+
46
+ it("each format has id, name, and allowedExams", () => {
47
+ for (const f of FORMATS) {
48
+ expect(f).toHaveProperty("id");
49
+ expect(f).toHaveProperty("name");
50
+ expect(f).toHaveProperty("allowedExams");
51
+ expect(Array.isArray(f.allowedExams)).toBe(true);
52
+ }
53
+ });
54
+
55
+ it("no duplicate format IDs", () => {
56
+ const ids = FORMATS.map((f) => f.id);
57
+ expect(new Set(ids).size).toBe(ids.length);
58
+ });
59
+ });
60
+
61
+ describe("TOPICS", () => {
62
+ it("has at least one topic", () => {
63
+ expect(TOPICS.length).toBeGreaterThanOrEqual(1);
64
+ });
65
+
66
+ it("all topics are strings", () => {
67
+ for (const t of TOPICS) {
68
+ expect(typeof t).toBe("string");
69
+ }
70
+ });
71
+ });
72
+
73
+ describe("DIFFICULTIES", () => {
74
+ it("has 5 difficulty strings", () => {
75
+ expect(DIFFICULTIES).toHaveLength(5);
76
+ });
77
+ });
78
+
79
+ describe("QUESTION_COUNT_PRESETS", () => {
80
+ it("has 4 presets", () => {
81
+ expect(QUESTION_COUNT_PRESETS).toHaveLength(4);
82
+ });
83
+
84
+ it("each preset has value, label, and desc", () => {
85
+ for (const p of QUESTION_COUNT_PRESETS) {
86
+ expect(p).toHaveProperty("value");
87
+ expect(p).toHaveProperty("label");
88
+ expect(p).toHaveProperty("desc");
89
+ }
90
+ });
91
+ });
apps/web/src/lib/__tests__/time.test.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "bun:test";
2
+ import { formatTime } from "../time";
3
+
4
+ describe("formatTime", () => {
5
+ it("formats zero seconds", () => {
6
+ expect(formatTime(0)).toBe("00:00");
7
+ });
8
+
9
+ it("formats seconds only (< 60)", () => {
10
+ expect(formatTime(5)).toBe("00:05");
11
+ expect(formatTime(45)).toBe("00:45");
12
+ expect(formatTime(59)).toBe("00:59");
13
+ });
14
+
15
+ it("formats exactly one minute", () => {
16
+ expect(formatTime(60)).toBe("01:00");
17
+ });
18
+
19
+ it("formats minutes and seconds", () => {
20
+ expect(formatTime(90)).toBe("01:30");
21
+ expect(formatTime(125)).toBe("02:05");
22
+ expect(formatTime(3661)).toBe("61:01");
23
+ });
24
+
25
+ it("pads single-digit minutes and seconds", () => {
26
+ expect(formatTime(7)).toBe("00:07");
27
+ expect(formatTime(67)).toBe("01:07");
28
+ });
29
+
30
+ it("handles large values", () => {
31
+ expect(formatTime(3600)).toBe("60:00");
32
+ expect(formatTime(10000)).toBe("166:40");
33
+ });
34
+ });
apps/web/src/routes/bank.tsx CHANGED
@@ -33,6 +33,7 @@ export const Route = createFileRoute("/bank")({
33
  section: z.string().optional(),
34
  format: z.string().optional(),
35
  difficulty: z.coerce.number().optional(),
 
36
  }).parse,
37
  beforeLoad: async () => {
38
  const session = await authClient.getSession();
@@ -59,12 +60,13 @@ function BankComponent() {
59
  const section = search.section ?? "";
60
  const format = search.format ?? "";
61
  const difficulty = search.difficulty;
 
62
 
63
  // ── Infinite scroll state ──
64
  const [allQuestions, setAllQuestions] = useState<any[]>([]);
65
  const [offset, setOffset] = useState(0);
66
  const limit = 12;
67
- const filterKey = JSON.stringify({ searchText, examType, section, format, difficulty, tab, mode });
68
 
69
  // ── Sidebar / Bundle State ──
70
  const [bundleQuestions, setBundleQuestions] = useState<any[]>([]);
@@ -82,6 +84,10 @@ function BankComponent() {
82
  const [isMobileSheetOpen, setIsMobileSheetOpen] = useState(false);
83
 
84
  // ── Data Queries ──
 
 
 
 
85
  const questionQuery = useQuery(
86
  trpc.question.list.queryOptions(
87
  {
@@ -91,7 +97,7 @@ function BankComponent() {
91
  format: format || undefined,
92
  difficulty,
93
  ...(tab === "mine" && userId
94
- ? { creatorUserId: userId }
95
  : { isPublic: true }),
96
  limit,
97
  offset,
@@ -155,11 +161,20 @@ function BankComponent() {
155
 
156
  const bulkPublish = useMutation({
157
  ...trpc.question.bulkPublish.mutationOptions(),
158
- onSuccess: () => {
159
  questionQuery.refetch();
160
- toast.success("Soal berhasil dipublikasikan");
 
 
 
 
 
 
 
 
 
 
161
  },
162
- onError: (err: any) => toast.error("Gagal mempublikasikan", { description: err.message }),
163
  });
164
 
165
  // ── Navigation helpers ──
@@ -194,6 +209,9 @@ function BankComponent() {
194
  const setDifficulty = (value: number | undefined) =>
195
  navigate({ search: (prev) => ({ ...prev, difficulty: value }) });
196
 
 
 
 
197
  const setTab = (newTab: QuestionTab) =>
198
  navigate({
199
  search: {
@@ -204,6 +222,7 @@ function BankComponent() {
204
  section: "",
205
  format: "",
206
  difficulty: undefined,
 
207
  },
208
  });
209
 
@@ -216,6 +235,7 @@ function BankComponent() {
216
  section: "",
217
  format: "",
218
  difficulty: undefined,
 
219
  }),
220
  });
221
 
@@ -389,6 +409,7 @@ function BankComponent() {
389
  tab={tab}
390
  searchText={searchText}
391
  examType={examType}
 
392
  activeChips={activeChips}
393
  hasFilters={hasFilters}
394
  isAdvancedOpen={isAdvancedOpen}
@@ -399,6 +420,7 @@ function BankComponent() {
399
  onSetTab={setTab}
400
  onSetSearch={setSearch}
401
  onSetExamType={setExamType}
 
402
  onClearFilters={clearFilters}
403
  onOpenMobileSheet={() => setIsMobileSheetOpen(true)}
404
  advancedFilters={
@@ -456,6 +478,7 @@ function BankComponent() {
456
  }}
457
  onClearFilters={clearFilters}
458
  onBulkPublish={(ids) => bulkPublish.mutate({ ids })}
 
459
  />
460
  ) : (
461
  <SectionBrowser
 
33
  section: z.string().optional(),
34
  format: z.string().optional(),
35
  difficulty: z.coerce.number().optional(),
36
+ visibility: z.enum(["all", "private", "public"]).optional(),
37
  }).parse,
38
  beforeLoad: async () => {
39
  const session = await authClient.getSession();
 
60
  const section = search.section ?? "";
61
  const format = search.format ?? "";
62
  const difficulty = search.difficulty;
63
+ const visibilityFilter = search.visibility ?? "all";
64
 
65
  // ── Infinite scroll state ──
66
  const [allQuestions, setAllQuestions] = useState<any[]>([]);
67
  const [offset, setOffset] = useState(0);
68
  const limit = 12;
69
+ const filterKey = JSON.stringify({ searchText, examType, section, format, difficulty, tab, mode, visibility: visibilityFilter });
70
 
71
  // ── Sidebar / Bundle State ──
72
  const [bundleQuestions, setBundleQuestions] = useState<any[]>([]);
 
84
  const [isMobileSheetOpen, setIsMobileSheetOpen] = useState(false);
85
 
86
  // ── Data Queries ──
87
+ const visibilityFilterParam = tab === "mine" && visibilityFilter !== "all"
88
+ ? { isPublic: visibilityFilter === "public" }
89
+ : {};
90
+
91
  const questionQuery = useQuery(
92
  trpc.question.list.queryOptions(
93
  {
 
97
  format: format || undefined,
98
  difficulty,
99
  ...(tab === "mine" && userId
100
+ ? { creatorUserId: userId, ...visibilityFilterParam }
101
  : { isPublic: true }),
102
  limit,
103
  offset,
 
161
 
162
  const bulkPublish = useMutation({
163
  ...trpc.question.bulkPublish.mutationOptions(),
164
+ onSuccess: (data) => {
165
  questionQuery.refetch();
166
+ if (data.skipped > 0) {
167
+ toast.success(
168
+ `${data.updated} soal dipublikasikan, ${data.skipped} dilewati`,
169
+ { description: "Beberapa soal bukan milikmu atau sudah tidak tersedia." },
170
+ );
171
+ } else {
172
+ toast.success(`${data.updated} soal berhasil dipublikasikan`);
173
+ }
174
+ },
175
+ onError: (err: any) => {
176
+ toast.error("Gagal mempublikasikan. Coba refresh dan pilih ulang soal.", { description: err.message });
177
  },
 
178
  });
179
 
180
  // ── Navigation helpers ──
 
209
  const setDifficulty = (value: number | undefined) =>
210
  navigate({ search: (prev) => ({ ...prev, difficulty: value }) });
211
 
212
+ const setVisibility = (value: "all" | "private" | "public") =>
213
+ navigate({ search: (prev) => ({ ...prev, visibility: value === "all" ? undefined : value }) });
214
+
215
  const setTab = (newTab: QuestionTab) =>
216
  navigate({
217
  search: {
 
222
  section: "",
223
  format: "",
224
  difficulty: undefined,
225
+ visibility: undefined,
226
  },
227
  });
228
 
 
235
  section: "",
236
  format: "",
237
  difficulty: undefined,
238
+ visibility: undefined,
239
  }),
240
  });
241
 
 
409
  tab={tab}
410
  searchText={searchText}
411
  examType={examType}
412
+ visibility={visibilityFilter}
413
  activeChips={activeChips}
414
  hasFilters={hasFilters}
415
  isAdvancedOpen={isAdvancedOpen}
 
420
  onSetTab={setTab}
421
  onSetSearch={setSearch}
422
  onSetExamType={setExamType}
423
+ onSetVisibility={setVisibility}
424
  onClearFilters={clearFilters}
425
  onOpenMobileSheet={() => setIsMobileSheetOpen(true)}
426
  advancedFilters={
 
478
  }}
479
  onClearFilters={clearFilters}
480
  onBulkPublish={(ids) => bulkPublish.mutate({ ids })}
481
+ onPublishAllPrivate={(ids) => bulkPublish.mutate({ ids })}
482
  />
483
  ) : (
484
  <SectionBrowser
apps/web/src/routes/history.tsx CHANGED
@@ -1,3 +1,4 @@
 
1
  import { useQuery } from "@tanstack/react-query";
2
  import { createFileRoute, redirect, Link } from "@tanstack/react-router";
3
  import { z } from "zod";
@@ -5,12 +6,24 @@ import { authClient } from "@/lib/auth-client";
5
  import { trpc } from "@/utils/trpc";
6
  import { Button } from "@labas/ui/components/button";
7
  import { Card, CardContent } from "@labas/ui/components/card";
 
 
 
 
 
 
 
 
 
8
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
 
9
 
10
  export const Route = createFileRoute("/history")({
11
  component: HistoryComponent,
12
  validateSearch: z.object({
13
  page: z.coerce.number().optional(),
 
 
14
  }).parse,
15
  beforeLoad: async () => {
16
  const session = await authClient.getSession();
@@ -63,10 +76,38 @@ export function HistoryComponent() {
63
  const search = Route.useSearch();
64
  const navigate = Route.useNavigate();
65
  const page = search.page ?? 1;
 
 
66
  const limit = 12;
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  const query = useQuery(
69
- trpc.attempt.myAttempts.queryOptions({ limit, offset: (page - 1) * limit }),
 
 
 
 
 
70
  );
71
 
72
  const attempts = query.data?.attempts ?? [];
@@ -95,6 +136,32 @@ export function HistoryComponent() {
95
  </p>
96
  </div>
97
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  </div>
99
 
100
  {/* Results */}
 
1
+ import { useCallback, useRef, useState, useEffect } from "react";
2
  import { useQuery } from "@tanstack/react-query";
3
  import { createFileRoute, redirect, Link } from "@tanstack/react-router";
4
  import { z } from "zod";
 
6
  import { trpc } from "@/utils/trpc";
7
  import { Button } from "@labas/ui/components/button";
8
  import { Card, CardContent } from "@labas/ui/components/card";
9
+ import { Input } from "@labas/ui/components/input";
10
+ import {
11
+ Select,
12
+ SelectContent,
13
+ SelectGroup,
14
+ SelectItem,
15
+ SelectTrigger,
16
+ SelectValue,
17
+ } from "@labas/ui/components/select";
18
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
19
+ import { EXAM_TYPES } from "@/lib/exam-constants";
20
 
21
  export const Route = createFileRoute("/history")({
22
  component: HistoryComponent,
23
  validateSearch: z.object({
24
  page: z.coerce.number().optional(),
25
+ examTypeId: z.string().optional(),
26
+ search: z.string().optional(),
27
  }).parse,
28
  beforeLoad: async () => {
29
  const session = await authClient.getSession();
 
76
  const search = Route.useSearch();
77
  const navigate = Route.useNavigate();
78
  const page = search.page ?? 1;
79
+ const examTypeId = search.examTypeId ?? "";
80
+ const searchQuery = search.search ?? "";
81
  const limit = 12;
82
 
83
+ const [localSearch, setLocalSearch] = useState(searchQuery);
84
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
85
+
86
+ useEffect(() => {
87
+ setLocalSearch(searchQuery);
88
+ }, [searchQuery]);
89
+
90
+ useEffect(() => {
91
+ if (debounceRef.current) clearTimeout(debounceRef.current);
92
+ debounceRef.current = setTimeout(() => {
93
+ if (localSearch !== searchQuery) {
94
+ navigate({ search: (prev) => ({ ...prev, search: localSearch || undefined, page: 1 }) });
95
+ }
96
+ }, 300);
97
+ return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
98
+ }, [localSearch]);
99
+
100
+ const setExamTypeFilter = useCallback((value: string) => {
101
+ navigate({ search: (prev) => ({ ...prev, examTypeId: value || undefined, page: 1 }) });
102
+ }, [navigate]);
103
+
104
  const query = useQuery(
105
+ trpc.attempt.myAttempts.queryOptions({
106
+ limit,
107
+ offset: (page - 1) * limit,
108
+ examTypeId: examTypeId || undefined,
109
+ search: searchQuery || undefined,
110
+ }),
111
  );
112
 
113
  const attempts = query.data?.attempts ?? [];
 
136
  </p>
137
  </div>
138
  </div>
139
+
140
+ {/* Filters */}
141
+ <div className="flex flex-col md:flex-row gap-3 mt-6">
142
+ <div className="relative flex-1 max-w-md">
143
+ <MaterialIcon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--warm-charcoal)]" />
144
+ <Input
145
+ placeholder="Cari paket..."
146
+ value={localSearch}
147
+ onChange={(e) => setLocalSearch(e.target.value)}
148
+ className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
149
+ />
150
+ </div>
151
+ <Select value={examTypeId} onValueChange={(v) => setExamTypeFilter(v ?? "")}>
152
+ <SelectTrigger className="w-36">
153
+ <SelectValue placeholder="Semua Ujian" />
154
+ </SelectTrigger>
155
+ <SelectContent>
156
+ <SelectGroup>
157
+ <SelectItem value="">Semua Ujian</SelectItem>
158
+ {EXAM_TYPES.map((t) => (
159
+ <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
160
+ ))}
161
+ </SelectGroup>
162
+ </SelectContent>
163
+ </Select>
164
+ </div>
165
  </div>
166
 
167
  {/* Results */}
bun.lock CHANGED
@@ -116,6 +116,7 @@
116
  "zod": "catalog:",
117
  },
118
  "devDependencies": {
 
119
  "@labas/config": "workspace:*",
120
  "@types/bcryptjs": "^3.0.0",
121
  "@types/nodemailer": "^8.0.0",
@@ -435,6 +436,8 @@
435
 
436
  "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="],
437
 
 
 
438
  "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
439
 
440
  "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
 
116
  "zod": "catalog:",
117
  },
118
  "devDependencies": {
119
+ "@electric-sql/pglite": "^0.4.5",
120
  "@labas/config": "workspace:*",
121
  "@types/bcryptjs": "^3.0.0",
122
  "@types/nodemailer": "^8.0.0",
 
436
 
437
  "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="],
438
 
439
+ "@electric-sql/pglite": ["@electric-sql/pglite@0.4.5", "", {}, "sha512-aGG2zGEyZzGWKy8P+9ZoNUV0jxt1+hgbeTf+bVAYyxVZZLXg3/9aFlfLxb08AYZVAfAkQlQIysmWjhc5hwDG8g=="],
440
+
441
  "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
442
 
443
  "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
package.json CHANGED
@@ -23,6 +23,7 @@
23
  "scripts": {
24
  "dev": "turbo dev",
25
  "build": "turbo build",
 
26
  "check-types": "turbo check-types",
27
  "dev:web": "turbo -F web dev",
28
  "dev:server": "turbo -F server dev",
 
23
  "scripts": {
24
  "dev": "turbo dev",
25
  "build": "turbo build",
26
+ "test": "turbo test",
27
  "check-types": "turbo check-types",
28
  "dev:web": "turbo -F web dev",
29
  "dev:server": "turbo -F server dev",
packages/ai/package.json CHANGED
@@ -14,6 +14,9 @@
14
  "zod": "catalog:",
15
  "zod-to-json-schema": "^3.25.2"
16
  },
 
 
 
17
  "devDependencies": {
18
  "@labas/config": "workspace:*",
19
  "typescript": "catalog:"
 
14
  "zod": "catalog:",
15
  "zod-to-json-schema": "^3.25.2"
16
  },
17
+ "scripts": {
18
+ "test": "bun test"
19
+ },
20
  "devDependencies": {
21
  "@labas/config": "workspace:*",
22
  "typescript": "catalog:"
packages/ai/src/__tests__/client.test.ts ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, beforeAll, afterAll } from "bun:test";
2
+ import { OpenAICompatibleClient } from "../client";
3
+
4
+ const BASE_URL = "https://api.openai.com/v1";
5
+ const API_KEY = "sk-test-key-12345";
6
+ const MODEL = "gpt-4";
7
+
8
+ function makeMockStream(chunks: string[]): ReadableStream {
9
+ const encoder = new TextEncoder();
10
+ return new ReadableStream({
11
+ async start(controller) {
12
+ for (const chunk of chunks) {
13
+ controller.enqueue(encoder.encode(chunk));
14
+ }
15
+ controller.close();
16
+ },
17
+ });
18
+ }
19
+
20
+ function makeFetchMock(streamChunks: string[], status = 200) {
21
+ return async () =>
22
+ new Response(makeMockStream(streamChunks), {
23
+ status,
24
+ headers: { "content-type": "text/event-stream" },
25
+ });
26
+ }
27
+
28
+ describe("OpenAICompatibleClient", () => {
29
+ let originalFetch: typeof globalThis.fetch;
30
+
31
+ beforeAll(() => {
32
+ originalFetch = globalThis.fetch;
33
+ });
34
+
35
+ afterAll(() => {
36
+ globalThis.fetch = originalFetch;
37
+ });
38
+
39
+ it("sends correct request and parses SSE response", async () => {
40
+ globalThis.fetch = makeFetchMock([
41
+ `data: ${JSON.stringify({ choices: [{ delta: { content: "Hello" } }] })}\n`,
42
+ `data: ${JSON.stringify({ choices: [{ delta: { content: " World" } }] })}\n`,
43
+ `data: ${JSON.stringify({ choices: [{ delta: {} }], usage: { total_tokens: 10 } })}\n`,
44
+ "data: [DONE]\n",
45
+ ]);
46
+
47
+ const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
48
+ const result = await client.chatCompletion({
49
+ model: MODEL,
50
+ messages: [{ role: "user", content: "Test" }],
51
+ });
52
+
53
+ expect(result.content).toBe("Hello World");
54
+ });
55
+
56
+ it("calls onToken callback for each token", async () => {
57
+ globalThis.fetch = makeFetchMock([
58
+ `data: ${JSON.stringify({ choices: [{ delta: { content: "A" } }] })}\n`,
59
+ `data: ${JSON.stringify({ choices: [{ delta: { content: "B" } }] })}\n`,
60
+ "data: [DONE]\n",
61
+ ]);
62
+
63
+ const tokens: string[] = [];
64
+ const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
65
+ await client.chatCompletion(
66
+ { model: MODEL, messages: [{ role: "user", content: "Test" }] },
67
+ { onToken: (t) => tokens.push(t) },
68
+ );
69
+
70
+ expect(tokens).toEqual(["A", "B"]);
71
+ });
72
+
73
+ it("returns usage from the last chunk", async () => {
74
+ globalThis.fetch = makeFetchMock([
75
+ `data: ${JSON.stringify({ choices: [{ delta: { content: "Hi" } }] })}\n`,
76
+ `data: ${JSON.stringify({ choices: [{ delta: {} }], usage: { total_tokens: 5 } })}\n`,
77
+ "data: [DONE]\n",
78
+ ]);
79
+
80
+ const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
81
+ const result = await client.chatCompletion({
82
+ model: MODEL,
83
+ messages: [{ role: "user", content: "Test" }],
84
+ });
85
+
86
+ expect(result.usage?.total_tokens).toBe(5);
87
+ });
88
+
89
+ it("throws on non-OK response", async () => {
90
+ globalThis.fetch = async () =>
91
+ new Response("Bad Request", { status: 400, headers: { "content-type": "text/plain" } });
92
+
93
+ const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
94
+ expect(
95
+ client.chatCompletion({
96
+ model: MODEL,
97
+ messages: [{ role: "user", content: "Test" }],
98
+ }),
99
+ ).rejects.toThrow("400");
100
+ });
101
+
102
+ it("throws on empty response body", async () => {
103
+ globalThis.fetch = async () =>
104
+ new Response(null, { status: 200 });
105
+
106
+ const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
107
+ expect(
108
+ client.chatCompletion({
109
+ model: MODEL,
110
+ messages: [{ role: "user", content: "Test" }],
111
+ }),
112
+ ).rejects.toThrow("Empty response body");
113
+ });
114
+
115
+ it("throws on invalid base URL", async () => {
116
+ const client = new OpenAICompatibleClient("not-a-url", API_KEY);
117
+ expect(
118
+ client.chatCompletion({
119
+ model: MODEL,
120
+ messages: [{ role: "user", content: "Test" }],
121
+ }),
122
+ ).rejects.toThrow("Invalid base URL");
123
+ });
124
+
125
+ it("throws on metadata host (169.254.169.254)", async () => {
126
+ const client = new OpenAICompatibleClient("https://169.254.169.254/v1", API_KEY);
127
+ expect(
128
+ client.chatCompletion({
129
+ model: MODEL,
130
+ messages: [{ role: "user", content: "Test" }],
131
+ }),
132
+ ).rejects.toThrow("metadata/private network");
133
+ });
134
+
135
+ it("retries without response_format on 400 with response_format error", async () => {
136
+ let callCount = 0;
137
+ globalThis.fetch = async (_url: string, opts: any) => {
138
+ callCount++;
139
+ if (callCount === 1) {
140
+ const body = JSON.parse(opts.body);
141
+ expect(body.response_format).toBeDefined();
142
+ return new Response("response_format is not supported", {
143
+ status: 400,
144
+ headers: { "content-type": "text/plain" },
145
+ });
146
+ }
147
+ const body = JSON.parse(opts.body);
148
+ expect(body.response_format).toBeUndefined();
149
+ return new Response(makeMockStream([
150
+ `data: ${JSON.stringify({ choices: [{ delta: { content: "retried" } }] })}\n`,
151
+ "data: [DONE]\n",
152
+ ]), {
153
+ status: 200,
154
+ headers: { "content-type": "text/event-stream" },
155
+ });
156
+ };
157
+
158
+ const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
159
+ const result = await client.chatCompletion({
160
+ model: MODEL,
161
+ messages: [{ role: "user", content: "Test" }],
162
+ response_format: { type: "json_object" },
163
+ });
164
+
165
+ expect(callCount).toBe(2);
166
+ expect(result.content).toBe("retried");
167
+ });
168
+
169
+ it("retries with more tokens on truncated response", async () => {
170
+ let callCount = 0;
171
+ globalThis.fetch = async (_url: string, opts: any) => {
172
+ callCount++;
173
+ const responseContent = callCount === 1
174
+ ? `data: ${JSON.stringify({ choices: [{ delta: { content: '{"incomplete":' } }] })}\n` + "data: [DONE]\n"
175
+ : `data: ${JSON.stringify({ choices: [{ delta: { content: '{"complete": true}' } }] })}\n` + "data: [DONE]\n";
176
+
177
+ return new Response(makeMockStream([responseContent]), {
178
+ status: 200,
179
+ headers: { "content-type": "text/event-stream" },
180
+ });
181
+ };
182
+
183
+ const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
184
+ const result = await client.chatCompletion({
185
+ model: MODEL,
186
+ messages: [{ role: "user", content: "Test" }],
187
+ max_tokens: 100,
188
+ });
189
+
190
+ expect(callCount).toBe(2);
191
+ expect(result.content).toBe('{"complete": true}');
192
+ });
193
+
194
+ it("throws on HTML response in stream", async () => {
195
+ globalThis.fetch = async () =>
196
+ new Response(makeMockStream([
197
+ `data: ${JSON.stringify({ choices: [{ delta: { content: "<html>Not JSON</html>" } }] })}\n`,
198
+ "data: [DONE]\n",
199
+ ]), {
200
+ status: 200,
201
+ headers: { "content-type": "text/event-stream" },
202
+ });
203
+
204
+ const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
205
+ expect(
206
+ client.chatCompletion({
207
+ model: MODEL,
208
+ messages: [{ role: "user", content: "Test" }],
209
+ }),
210
+ ).rejects.toThrow("HTML");
211
+ });
212
+ });
packages/ai/src/__tests__/errors.test.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "bun:test";
2
+ import { GenerationError } from "../errors";
3
+
4
+ describe("GenerationError", () => {
5
+ it("creates error with just a message", () => {
6
+ const err = new GenerationError("Something went wrong");
7
+ expect(err).toBeInstanceOf(Error);
8
+ expect(err.name).toBe("GenerationError");
9
+ expect(err.message).toBe("Something went wrong");
10
+ expect(err.tokensUsed).toBeUndefined();
11
+ expect(err.partialResult).toBeUndefined();
12
+ });
13
+
14
+ it("creates error with tokensUsed", () => {
15
+ const err = new GenerationError("Failed", { tokensUsed: 150 });
16
+ expect(err.tokensUsed).toBe(150);
17
+ });
18
+
19
+ it("creates error with partialResult", () => {
20
+ const partial = { questions: [{ format: "multiple_choice" }] };
21
+ const err = new GenerationError("Partial", { partialResult: partial });
22
+ expect(err.partialResult).toEqual(partial);
23
+ });
24
+ });
packages/ai/src/__tests__/prompts.test.ts ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "bun:test";
2
+ import { buildQuickModePrompt } from "../prompts";
3
+ import type { GenerationInput } from "../schemas";
4
+
5
+ const baseInput = {
6
+ examType: "IELTS" as const,
7
+ section: "READING" as const,
8
+ formats: ["multiple_choice", "true_false_not_given"],
9
+ difficulty: 3,
10
+ topics: ["education", "technology"],
11
+ questionCount: 10,
12
+ mode: "quick" as const,
13
+ apiKeyConfig: {
14
+ baseUrl: "https://api.openai.com/v1",
15
+ apiKey: "sk-test",
16
+ model: "gpt-4",
17
+ maxTokens: 16384,
18
+ },
19
+ } satisfies GenerationInput;
20
+
21
+ describe("buildQuickModePrompt", () => {
22
+ it("returns a string", () => {
23
+ const prompt = buildQuickModePrompt(baseInput);
24
+ expect(typeof prompt).toBe("string");
25
+ expect(prompt.length).toBeGreaterThan(0);
26
+ });
27
+
28
+ it("contains exam type, section, difficulty, topics, formats", () => {
29
+ const prompt = buildQuickModePrompt(baseInput);
30
+ expect(prompt).toContain("IELTS");
31
+ expect(prompt).toContain("READING");
32
+ expect(prompt).toContain("3/5");
33
+ expect(prompt).toContain("education, technology");
34
+ expect(prompt).toContain("multiple_choice, true_false_not_given");
35
+ });
36
+
37
+ it("includes question count", () => {
38
+ const prompt = buildQuickModePrompt(baseInput);
39
+ expect(prompt).toContain("Generate 10");
40
+ });
41
+
42
+ it("sets language to English for IELTS", () => {
43
+ const prompt = buildQuickModePrompt(baseInput);
44
+ expect(prompt).toContain("English");
45
+ });
46
+
47
+ it("sets language to Japanese for JLPT", () => {
48
+ const prompt = buildQuickModePrompt({
49
+ ...baseInput,
50
+ examType: "JLPT",
51
+ });
52
+ expect(prompt).toContain("Japanese");
53
+ });
54
+
55
+ it("sets language to Chinese for HSK", () => {
56
+ const prompt = buildQuickModePrompt({
57
+ ...baseInput,
58
+ examType: "HSK",
59
+ });
60
+ expect(prompt).toContain("Chinese");
61
+ });
62
+
63
+ it("sets language to German for GOETHE", () => {
64
+ const prompt = buildQuickModePrompt({
65
+ ...baseInput,
66
+ examType: "GOETHE",
67
+ });
68
+ expect(prompt).toContain("German");
69
+ });
70
+
71
+ it("sets language to Korean for TOPIK", () => {
72
+ const prompt = buildQuickModePrompt({
73
+ ...baseInput,
74
+ examType: "TOPIK",
75
+ });
76
+ expect(prompt).toContain("Korean");
77
+ });
78
+
79
+ it("sets language to Arabic for TOAFL", () => {
80
+ const prompt = buildQuickModePrompt({
81
+ ...baseInput,
82
+ examType: "TOAFL",
83
+ });
84
+ expect(prompt).toContain("Arabic");
85
+ });
86
+
87
+ it("sets language to Spanish for DELE", () => {
88
+ const prompt = buildQuickModePrompt({
89
+ ...baseInput,
90
+ examType: "DELE",
91
+ });
92
+ expect(prompt).toContain("Spanish");
93
+ });
94
+
95
+ it("includes instruction about explanation language (Bahasa Indonesia)", () => {
96
+ const prompt = buildQuickModePrompt(baseInput);
97
+ expect(prompt).toContain("Bahasa Indonesia");
98
+ });
99
+
100
+ it("includes JSON schema in output", () => {
101
+ const prompt = buildQuickModePrompt(baseInput);
102
+ expect(prompt).toContain("JSON");
103
+ expect(prompt).toContain("questions");
104
+ });
105
+
106
+ it("includes TOPIK-specific instructions for Korean", () => {
107
+ const prompt = buildQuickModePrompt({ ...baseInput, examType: "TOPIK" });
108
+ expect(prompt).toContain("particles");
109
+ expect(prompt).toContain("honorifics");
110
+ });
111
+
112
+ it("includes TOAFL-specific instructions for Arabic", () => {
113
+ const prompt = buildQuickModePrompt({ ...baseInput, examType: "TOAFL" });
114
+ expect(prompt).toContain("I'rab");
115
+ expect(prompt).toContain("RTL");
116
+ });
117
+
118
+ it("includes DELE-specific instructions for Spanish", () => {
119
+ const prompt = buildQuickModePrompt({ ...baseInput, examType: "DELE" });
120
+ expect(prompt).toContain("verb conjugation");
121
+ });
122
+ });
packages/ai/src/__tests__/repair.test.ts ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ repairQuestion,
4
+ repairAndParseQuestions,
5
+ tryParseQuestion,
6
+ } from "../repair";
7
+
8
+ const fullPassage = "A".repeat(200);
9
+
10
+ const baseRaw = {
11
+ format: "multiple_choice",
12
+ passageText: fullPassage,
13
+ questionText: "What is the main idea of the passage?",
14
+ options: [
15
+ { key: "A", text: "First option" },
16
+ { key: "B", text: "Second option" },
17
+ { key: "C", text: "Third option" },
18
+ { key: "D", text: "Fourth option" },
19
+ ],
20
+ correctAnswer: "A",
21
+ explanation: "This is the correct answer because...",
22
+ difficulty: 3,
23
+ skillTags: ["comprehension"],
24
+ };
25
+
26
+ describe("repairQuestion", () => {
27
+ it("passes through a valid question unchanged", () => {
28
+ const { question, wasRepaired } = repairQuestion(baseRaw, fullPassage);
29
+ expect(question.format).toBe("multiple_choice");
30
+ expect(question.correctAnswer).toBe("A");
31
+ expect(wasRepaired).toBe(false);
32
+ });
33
+
34
+ it("replaces missing passageText with fullPassage", () => {
35
+ const raw = { ...baseRaw, passageText: "" };
36
+ const { question, wasRepaired, repairNotes } = repairQuestion(raw, fullPassage);
37
+ expect(question.passageText).toBe(fullPassage);
38
+ expect(wasRepaired).toBe(true);
39
+ expect(repairNotes).toContainEqual(expect.stringContaining("passageText"));
40
+ });
41
+
42
+ it("replaces too-short questionText with fallback", () => {
43
+ const raw = { ...baseRaw, questionText: "" };
44
+ const { question, wasRepaired } = repairQuestion(raw, fullPassage);
45
+ expect(question.questionText).toBeTruthy();
46
+ expect(wasRepaired).toBe(true);
47
+ });
48
+
49
+ it("adds fallback explanation when missing", () => {
50
+ const raw = { ...baseRaw, explanation: "" };
51
+ const { question, wasRepaired } = repairQuestion(raw, fullPassage);
52
+ expect(question.explanation).toBe("Penjelasan tidak tersedia.");
53
+ expect(wasRepaired).toBe(true);
54
+ });
55
+
56
+ it("adds fallback skillTags when missing", () => {
57
+ const raw = { ...baseRaw, skillTags: [] };
58
+ const { question, wasRepaired } = repairQuestion(raw, fullPassage);
59
+ expect(question.skillTags).toEqual(["comprehension"]);
60
+ expect(wasRepaired).toBe(true);
61
+ });
62
+
63
+ it("clamps invalid difficulty to 3", () => {
64
+ const raw = { ...baseRaw, difficulty: 99 };
65
+ const { question } = repairQuestion(raw, fullPassage);
66
+ expect(question.difficulty).toBe(3);
67
+ });
68
+
69
+ it("throws when raw is not an object", () => {
70
+ expect(() => repairQuestion(null, fullPassage)).toThrow("Question is not an object");
71
+ expect(() => repairQuestion("string", fullPassage)).toThrow("Question is not an object");
72
+ expect(() => repairQuestion(42, fullPassage)).toThrow("Question is not an object");
73
+ });
74
+ });
75
+
76
+ describe("coerceCorrectAnswer — true_false_not_given", () => {
77
+ it("coerces T to TRUE", () => {
78
+ const raw = { ...baseRaw, format: "true_false_not_given", correctAnswer: "T" };
79
+ const { question } = repairQuestion(raw, fullPassage);
80
+ expect(question.correctAnswer).toBe("TRUE");
81
+ });
82
+
83
+ it("coerces F to FALSE", () => {
84
+ const raw = { ...baseRaw, format: "true_false_not_given", correctAnswer: "F" };
85
+ const { question } = repairQuestion(raw, fullPassage);
86
+ expect(question.correctAnswer).toBe("FALSE");
87
+ });
88
+
89
+ it("coerces NG to NOT_GIVEN", () => {
90
+ const raw = { ...baseRaw, format: "true_false_not_given", correctAnswer: "NG" };
91
+ const { question } = repairQuestion(raw, fullPassage);
92
+ expect(question.correctAnswer).toBe("NOT_GIVEN");
93
+ });
94
+
95
+ it("coerces lowercase true to TRUE", () => {
96
+ const raw = { ...baseRaw, format: "true_false_not_given", correctAnswer: "true" };
97
+ const { question } = repairQuestion(raw, fullPassage);
98
+ expect(question.correctAnswer).toBe("TRUE");
99
+ });
100
+ });
101
+
102
+ describe("coerceCorrectAnswer — author_view", () => {
103
+ it("coerces Y to YES", () => {
104
+ const raw = { ...baseRaw, format: "author_view", correctAnswer: "Y" };
105
+ const { question } = repairQuestion(raw, fullPassage);
106
+ expect(question.correctAnswer).toBe("YES");
107
+ });
108
+
109
+ it("coerces N to NO", () => {
110
+ const raw = { ...baseRaw, format: "author_view", correctAnswer: "N" };
111
+ const { question } = repairQuestion(raw, fullPassage);
112
+ expect(question.correctAnswer).toBe("NO");
113
+ });
114
+ });
115
+
116
+ describe("coerceCorrectAnswer — multiple_choice with invalid key", () => {
117
+ it("falls back to first option key when answer key does not match", () => {
118
+ const raw = {
119
+ ...baseRaw,
120
+ format: "multiple_choice",
121
+ correctAnswer: "Z",
122
+ options: [
123
+ { key: "A", text: "First" },
124
+ { key: "B", text: "Second" },
125
+ ],
126
+ };
127
+ const { question } = repairQuestion(raw, fullPassage);
128
+ expect(question.correctAnswer).toBe("A");
129
+ });
130
+
131
+ it("normalizes answer key case", () => {
132
+ const raw = {
133
+ ...baseRaw,
134
+ format: "multiple_choice",
135
+ correctAnswer: "a",
136
+ options: [
137
+ { key: "A", text: "First" },
138
+ { key: "B", text: "Second" },
139
+ ],
140
+ };
141
+ const { question } = repairQuestion(raw, fullPassage);
142
+ expect(question.correctAnswer).toBe("A");
143
+ });
144
+ });
145
+
146
+ describe("ensureOptions", () => {
147
+ it("injects placeholder options when missing for multiple_choice", () => {
148
+ const raw = {
149
+ ...baseRaw,
150
+ format: "multiple_choice",
151
+ options: [],
152
+ };
153
+ const { question, wasRepaired } = repairQuestion(raw, fullPassage);
154
+ expect(question.options).toHaveLength(4);
155
+ expect(wasRepaired).toBe(true);
156
+ });
157
+
158
+ it("deduplicates options by key", () => {
159
+ const raw = {
160
+ ...baseRaw,
161
+ format: "multiple_choice",
162
+ options: [
163
+ { key: "A", text: "First" },
164
+ { key: "A", text: "Duplicate A" },
165
+ { key: "B", text: "Second" },
166
+ ],
167
+ };
168
+ const { question } = repairQuestion(raw, fullPassage);
169
+ expect(question.options).toHaveLength(2);
170
+ });
171
+ });
172
+
173
+ describe("repairAndParseQuestions", () => {
174
+ it("returns valid questions from well-formed input", () => {
175
+ const result = repairAndParseQuestions([baseRaw], fullPassage);
176
+ expect(result.valid).toHaveLength(1);
177
+ expect(result.invalid).toHaveLength(0);
178
+ });
179
+
180
+ it("repairs and processes mixed valid/invalid questions", () => {
181
+ const raw = [
182
+ baseRaw,
183
+ { format: "multiple_choice", passageText: "", questionText: "", correctAnswer: "", explanation: "", difficulty: 99, skillTags: [] },
184
+ ];
185
+ const result = repairAndParseQuestions(raw, fullPassage);
186
+ expect(result.valid).toHaveLength(2);
187
+ expect(result.invalid).toHaveLength(0);
188
+ expect(result.repairLog.length).toBeGreaterThan(0);
189
+ });
190
+
191
+ it("captures invalid questions that remain unparseable after repair", () => {
192
+ const raw = [
193
+ { format: "unknown_format", passageText: "", questionText: "" }, // Will fail repair entirely
194
+ ];
195
+ // First entry will fail during repairQuestion because format might cause issues
196
+ // Actually it won't crash but unknown format won't match discriminated union
197
+ const result = repairAndParseQuestions(raw, fullPassage);
198
+ expect(result.invalid).toHaveLength(1);
199
+ expect(result.valid).toHaveLength(0);
200
+ });
201
+ });
202
+
203
+ describe("tryParseQuestion", () => {
204
+ it("parses a valid generic question", () => {
205
+ const generic = {
206
+ ...baseRaw,
207
+ passageText: fullPassage,
208
+ };
209
+ const result = tryParseQuestion(generic as any);
210
+ expect(result).not.toBeNull();
211
+ expect(result!.format).toBe("multiple_choice");
212
+ });
213
+
214
+ it("returns null for invalid question", () => {
215
+ const result = tryParseQuestion({ format: "unknown_format" } as any);
216
+ expect(result).toBeNull();
217
+ });
218
+ });
packages/ai/src/__tests__/schema-to-prompt.test.ts ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ getQuestionJsonSchemaDescription,
4
+ getPassageJsonSchemaDescription,
5
+ getValidationJsonSchemaDescription,
6
+ getQuestionsArrayJsonSchemaDescription,
7
+ getSelfValidationJsonSchemaDescription,
8
+ } from "../schema-to-prompt";
9
+
10
+ describe("getQuestionJsonSchemaDescription", () => {
11
+ it("returns valid JSON string", () => {
12
+ const result = getQuestionJsonSchemaDescription();
13
+ expect(typeof result).toBe("string");
14
+ expect(() => JSON.parse(result)).not.toThrow();
15
+ });
16
+
17
+ it("contains oneOf with all question format variants", () => {
18
+ const result = JSON.parse(getQuestionJsonSchemaDescription());
19
+ expect(Array.isArray(result.oneOf)).toBe(true);
20
+ expect(result.oneOf.length).toBeGreaterThanOrEqual(20);
21
+ });
22
+
23
+ it("first variant has question properties", () => {
24
+ const result = JSON.parse(getQuestionJsonSchemaDescription());
25
+ const firstProps = result.oneOf[0].properties;
26
+ expect(firstProps.format).toBeDefined();
27
+ expect(firstProps.passageText).toBeDefined();
28
+ expect(firstProps.questionText).toBeDefined();
29
+ expect(firstProps.correctAnswer).toBeDefined();
30
+ expect(firstProps.explanation).toBeDefined();
31
+ });
32
+ });
33
+
34
+ describe("getPassageJsonSchemaDescription", () => {
35
+ it("returns valid JSON with passage schema", () => {
36
+ const result = JSON.parse(getPassageJsonSchemaDescription());
37
+ expect(result.required).toEqual(["title", "passage"]);
38
+ expect(result.properties.title).toBeDefined();
39
+ expect(result.properties.passage).toBeDefined();
40
+ });
41
+ });
42
+
43
+ describe("getValidationJsonSchemaDescription", () => {
44
+ it("returns valid JSON with validation schema", () => {
45
+ const result = JSON.parse(getValidationJsonSchemaDescription());
46
+ expect(result.required).toEqual(["isValid", "feedback", "score"]);
47
+ expect(result.properties.score.minimum).toBe(1);
48
+ expect(result.properties.score.maximum).toBe(10);
49
+ });
50
+ });
51
+
52
+ describe("getQuestionsArrayJsonSchemaDescription", () => {
53
+ it("returns valid JSON with questions array", () => {
54
+ const result = JSON.parse(getQuestionsArrayJsonSchemaDescription());
55
+ expect(result.required).toEqual(["questions"]);
56
+ expect(result.properties.questions.type).toBe("array");
57
+ });
58
+ });
59
+
60
+ describe("getSelfValidationJsonSchemaDescription", () => {
61
+ it("returns valid JSON with self-validation schema", () => {
62
+ const result = JSON.parse(getSelfValidationJsonSchemaDescription());
63
+ expect(result.required).toEqual(["overallConfidence", "issues", "needsRevision"]);
64
+ expect(result.properties.overallConfidence.minimum).toBe(0);
65
+ expect(result.properties.overallConfidence.maximum).toBe(100);
66
+ });
67
+ });
packages/ai/src/__tests__/schemas.test.ts ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ examTypeSchema,
4
+ sectionTypeSchema,
5
+ questionFormatSchema,
6
+ difficultySchema,
7
+ questionSchema,
8
+ generationInputSchema,
9
+ generationResultSchema,
10
+ multipleChoiceQuestionSchema,
11
+ trueFalseQuestionSchema,
12
+ } from "../schemas";
13
+
14
+ const base = {
15
+ passageText: "A".repeat(50),
16
+ questionText: "B".repeat(10),
17
+ correctAnswer: "A",
18
+ explanation: "Explanation text",
19
+ difficulty: 3,
20
+ skillTags: ["reading"],
21
+ };
22
+
23
+ describe("examTypeSchema", () => {
24
+ it("accepts valid exam types", () => {
25
+ expect(examTypeSchema.parse("IELTS")).toBe("IELTS");
26
+ expect(examTypeSchema.parse("TOEFL")).toBe("TOEFL");
27
+ expect(examTypeSchema.parse("JLPT")).toBe("JLPT");
28
+ });
29
+
30
+ it("rejects invalid exam type", () => {
31
+ expect(() => examTypeSchema.parse("GRE")).toThrow();
32
+ });
33
+ });
34
+
35
+ describe("sectionTypeSchema", () => {
36
+ it("accepts valid sections", () => {
37
+ expect(sectionTypeSchema.parse("READING")).toBe("READING");
38
+ expect(sectionTypeSchema.parse("LISTENING")).toBe("LISTENING");
39
+ });
40
+
41
+ it("rejects invalid section", () => {
42
+ expect(() => sectionTypeSchema.parse("MATH")).toThrow();
43
+ });
44
+ });
45
+
46
+ describe("questionFormatSchema", () => {
47
+ it("accepts all 20 format variants", () => {
48
+ const formats = [
49
+ "multiple_choice", "true_false_not_given", "matching_headings",
50
+ "matching_information", "matching_pairs", "fill_blank",
51
+ "synonym", "grammar_in_context", "sentence_completion",
52
+ "summary_completion", "cloze", "reference", "author_view",
53
+ "error_recognition", "text_insertion", "kanji_reading",
54
+ "particle_choice", "article_case", "character_reading",
55
+ "sentence_arrangement",
56
+ ];
57
+ for (const f of formats) {
58
+ expect(questionFormatSchema.parse(f)).toBe(f);
59
+ }
60
+ });
61
+ });
62
+
63
+ describe("difficultySchema", () => {
64
+ it("accepts values 1-5", () => {
65
+ for (let i = 1; i <= 5; i++) {
66
+ expect(difficultySchema.parse(i)).toBe(i);
67
+ }
68
+ });
69
+
70
+ it("rejects values outside 1-5", () => {
71
+ expect(() => difficultySchema.parse(0)).toThrow();
72
+ expect(() => difficultySchema.parse(6)).toThrow();
73
+ });
74
+
75
+ it("rejects non-integer", () => {
76
+ expect(() => difficultySchema.parse(2.5)).toThrow();
77
+ });
78
+ });
79
+
80
+ describe("multipleChoiceQuestionSchema", () => {
81
+ it("accepts valid multiple choice", () => {
82
+ const data = {
83
+ ...base,
84
+ format: "multiple_choice",
85
+ options: [
86
+ { key: "A", text: "Option A" },
87
+ { key: "B", text: "Option B" },
88
+ ],
89
+ };
90
+ const parsed = multipleChoiceQuestionSchema.parse(data);
91
+ expect(parsed.format).toBe("multiple_choice");
92
+ expect(parsed.options).toHaveLength(2);
93
+ });
94
+
95
+ it("rejects fewer than 2 options", () => {
96
+ expect(() =>
97
+ multipleChoiceQuestionSchema.parse({
98
+ ...base,
99
+ format: "multiple_choice",
100
+ options: [{ key: "A", text: "Only option" }],
101
+ })
102
+ ).toThrow();
103
+ });
104
+
105
+ it("rejects more than 6 options", () => {
106
+ expect(() =>
107
+ multipleChoiceQuestionSchema.parse({
108
+ ...base,
109
+ format: "multiple_choice",
110
+ options: "ABCDEFG".split("").map((k) => ({ key: k, text: `Option ${k}` })),
111
+ })
112
+ ).toThrow();
113
+ });
114
+ });
115
+
116
+ describe("trueFalseQuestionSchema", () => {
117
+ it("accepts TRUE answer", () => {
118
+ const data = { ...base, format: "true_false_not_given", correctAnswer: "TRUE" };
119
+ expect(trueFalseQuestionSchema.parse(data).correctAnswer).toBe("TRUE");
120
+ });
121
+
122
+ it("accepts FALSE answer", () => {
123
+ const data = { ...base, format: "true_false_not_given", correctAnswer: "FALSE" };
124
+ expect(trueFalseQuestionSchema.parse(data).correctAnswer).toBe("FALSE");
125
+ });
126
+
127
+ it("accepts NOT_GIVEN answer", () => {
128
+ const data = { ...base, format: "true_false_not_given", correctAnswer: "NOT_GIVEN" };
129
+ expect(trueFalseQuestionSchema.parse(data).correctAnswer).toBe("NOT_GIVEN");
130
+ });
131
+
132
+ it("rejects invalid answer", () => {
133
+ expect(() =>
134
+ trueFalseQuestionSchema.parse({ ...base, format: "true_false_not_given", correctAnswer: "MAYBE" })
135
+ ).toThrow();
136
+ });
137
+ });
138
+
139
+ describe("questionSchema (discriminated union)", () => {
140
+ it("parses a valid multiple_choice question", () => {
141
+ const data = {
142
+ ...base,
143
+ format: "multiple_choice",
144
+ options: [
145
+ { key: "A", text: "Option A" },
146
+ { key: "B", text: "Option B" },
147
+ { key: "C", text: "Option C" },
148
+ { key: "D", text: "Option D" },
149
+ ],
150
+ };
151
+ expect(() => questionSchema.parse(data)).not.toThrow();
152
+ });
153
+
154
+ it("parses a valid true_false_not_given question", () => {
155
+ const data = { ...base, format: "true_false_not_given", correctAnswer: "TRUE" };
156
+ expect(() => questionSchema.parse(data)).not.toThrow();
157
+ });
158
+
159
+ it("parses a valid fill_blank question (no options needed)", () => {
160
+ const data = { ...base, format: "fill_blank", correctAnswer: "the answer" };
161
+ expect(() => questionSchema.parse(data)).not.toThrow();
162
+ });
163
+
164
+ it("rejects mismatched format — correctAnswer 'TRUE' on non-TFNG format", () => {
165
+ // A fill_blank with answer "TRUE" should still parse (it's just a string)
166
+ const data = { ...base, format: "fill_blank", correctAnswer: "TRUE" };
167
+ expect(() => questionSchema.parse(data)).not.toThrow();
168
+ });
169
+
170
+ it("rejects missing required options for multiple_choice", () => {
171
+ expect(() =>
172
+ questionSchema.parse({ ...base, format: "multiple_choice" })
173
+ ).toThrow();
174
+ });
175
+
176
+ it("rejects unknown format", () => {
177
+ expect(() =>
178
+ questionSchema.parse({ ...base, format: "unknown_format" as any })
179
+ ).toThrow();
180
+ });
181
+
182
+ it("rejects passageText shorter than 50 chars", () => {
183
+ expect(() =>
184
+ questionSchema.parse({
185
+ ...base,
186
+ format: "fill_blank",
187
+ passageText: "too short",
188
+ correctAnswer: "ans",
189
+ })
190
+ ).toThrow();
191
+ });
192
+
193
+ it("rejects skillTags with empty array", () => {
194
+ expect(() =>
195
+ questionSchema.parse({
196
+ ...base,
197
+ format: "fill_blank",
198
+ skillTags: [],
199
+ correctAnswer: "ans",
200
+ })
201
+ ).toThrow();
202
+ });
203
+ });
204
+
205
+ describe("generationInputSchema", () => {
206
+ it("accepts valid generation input", () => {
207
+ const data = {
208
+ examType: "IELTS",
209
+ section: "READING",
210
+ formats: ["multiple_choice", "true_false_not_given"],
211
+ difficulty: 3,
212
+ topics: ["education", "technology"],
213
+ questionCount: 10,
214
+ mode: "quick",
215
+ apiKeyConfig: {
216
+ baseUrl: "https://api.openai.com/v1",
217
+ apiKey: "sk-test",
218
+ model: "gpt-4",
219
+ },
220
+ };
221
+ expect(() => generationInputSchema.parse(data)).not.toThrow();
222
+ });
223
+
224
+ it("rejects questionCount over 40", () => {
225
+ expect(() =>
226
+ generationInputSchema.parse({
227
+ examType: "IELTS",
228
+ section: "READING",
229
+ formats: ["multiple_choice"],
230
+ difficulty: 3,
231
+ topics: ["education"],
232
+ questionCount: 100,
233
+ apiKeyConfig: {
234
+ baseUrl: "https://api.openai.com/v1",
235
+ apiKey: "sk-test",
236
+ model: "gpt-4",
237
+ },
238
+ })
239
+ ).toThrow();
240
+ });
241
+ });
242
+
243
+ describe("generationResultSchema", () => {
244
+ it("accepts valid generation result", () => {
245
+ const data = {
246
+ questions: [
247
+ {
248
+ ...base,
249
+ format: "multiple_choice",
250
+ options: [
251
+ { key: "A", text: "Op A" },
252
+ { key: "B", text: "Op B" },
253
+ ],
254
+ },
255
+ ],
256
+ meta: {
257
+ model: "gpt-4",
258
+ tokensUsed: 500,
259
+ durationMs: 1234,
260
+ mode: "quick",
261
+ },
262
+ };
263
+ expect(() => generationResultSchema.parse(data)).not.toThrow();
264
+ });
265
+ });
packages/ai/src/client.ts CHANGED
@@ -104,7 +104,7 @@ function looksTruncated(content: string): boolean {
104
  const lastChar = trimmed[trimmed.length - 1];
105
  if (lastChar === "}" || lastChar === "]") return false;
106
  // Check for common truncation signatures
107
- const unterminated = /Unterminated string|Unexpected end of JSON|Unexpected token/i;
108
  try {
109
  JSON.parse(trimmed);
110
  return false;
 
104
  const lastChar = trimmed[trimmed.length - 1];
105
  if (lastChar === "}" || lastChar === "]") return false;
106
  // Check for common truncation signatures
107
+ const unterminated = /Unterminated string|Unexpected end of JSON|Unexpected (token|EOF)|Expected ('.*'|".*")/i;
108
  try {
109
  JSON.parse(trimmed);
110
  return false;
packages/ai/src/schema-to-prompt.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { zodToJsonSchema } from "zod-to-json-schema";
2
  import { questionSchema } from "./schemas";
3
 
4
  /**
@@ -6,15 +6,8 @@ import { questionSchema } from "./schemas";
6
  * suitable for embedding into an LLM prompt.
7
  */
8
  export function getQuestionJsonSchemaDescription(): string {
9
- const jsonSchema = zodToJsonSchema(questionSchema as any, {
10
- name: "Question",
11
- $refStrategy: "none",
12
- });
13
-
14
- // Strip the top-level wrapper so the AI sees just the object shape
15
- const defs = (jsonSchema as any).definitions?.Question ?? jsonSchema;
16
-
17
- return JSON.stringify(defs, null, 2);
18
  }
19
 
20
  /**
 
1
+ import { toJSONSchema } from "zod";
2
  import { questionSchema } from "./schemas";
3
 
4
  /**
 
6
  * suitable for embedding into an LLM prompt.
7
  */
8
  export function getQuestionJsonSchemaDescription(): string {
9
+ const jsonSchema = toJSONSchema(questionSchema);
10
+ return JSON.stringify(jsonSchema, null, 2);
 
 
 
 
 
 
 
11
  }
12
 
13
  /**
packages/api/package.json CHANGED
@@ -10,7 +10,9 @@
10
  "default": "./src/*.ts"
11
  }
12
  },
13
- "scripts": {},
 
 
14
  "dependencies": {
15
  "@labas/ai": "workspace:*",
16
  "@labas/auth": "workspace:*",
@@ -28,6 +30,7 @@
28
  "zod": "catalog:"
29
  },
30
  "devDependencies": {
 
31
  "@labas/config": "workspace:*",
32
  "@types/bcryptjs": "^3.0.0",
33
  "@types/nodemailer": "^8.0.0",
 
10
  "default": "./src/*.ts"
11
  }
12
  },
13
+ "scripts": {
14
+ "test": "bun test"
15
+ },
16
  "dependencies": {
17
  "@labas/ai": "workspace:*",
18
  "@labas/auth": "workspace:*",
 
30
  "zod": "catalog:"
31
  },
32
  "devDependencies": {
33
+ "@electric-sql/pglite": "^0.4.5",
34
  "@labas/config": "workspace:*",
35
  "@types/bcryptjs": "^3.0.0",
36
  "@types/nodemailer": "^8.0.0",
packages/api/src/__tests__/attempt.integration.test.ts ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, beforeAll, afterAll, mock } from "bun:test";
2
+ import { drizzle } from "drizzle-orm/pglite";
3
+ import * as schema from "../../../db/src/schema";
4
+ import { closeTestPGlite, getTestPGlite } from "./test-setup";
5
+
6
+ mock.module("@labas/env/server", () => ({
7
+ env: {
8
+ DATABASE_URL: "postgres://localhost:5432/test",
9
+ BETTER_AUTH_SECRET: "a".repeat(32),
10
+ BETTER_AUTH_URL: "http://localhost:3000",
11
+ CORS_ORIGIN: "http://localhost:5173",
12
+ API_KEY_ENCRYPTION_KEY: "z".repeat(32),
13
+ REDIS_URL: "redis://localhost:6379",
14
+ SMTP_HOST: "localhost",
15
+ SMTP_USER: "test",
16
+ SMTP_PASS: "test",
17
+ SMTP_FROM: "test@test.com",
18
+ },
19
+ }));
20
+
21
+ describe("attempt router", () => {
22
+ let attemptRouter: any;
23
+ let packageId: string;
24
+ let questionIds: string[];
25
+ let testDb: any;
26
+
27
+ function makeCaller(userId: string) {
28
+ return attemptRouter.createCaller({
29
+ session: { user: { id: userId }, expiresAt: new Date() },
30
+ auth: null,
31
+ });
32
+ }
33
+
34
+ async function createUserAndCaller(label: string) {
35
+ const id = `user-${label}-${Date.now()}`;
36
+ await testDb.insert(schema.user).values({ id, name: `User ${label}`, email: `${label}@test.com` });
37
+ return { id, caller: makeCaller(id) };
38
+ }
39
+
40
+ beforeAll(async () => {
41
+ const pg = await getTestPGlite();
42
+ testDb = drizzle(pg, { schema });
43
+
44
+ mock.module("@labas/db", () => ({
45
+ __esModule: true,
46
+ ...schema,
47
+ db: testDb,
48
+ }));
49
+
50
+ const mod = await import("../routers/attempt");
51
+ attemptRouter = mod.attemptRouter;
52
+
53
+ await testDb.insert(schema.examType).values([
54
+ { id: "IELTS", name: "IELTS", language: "English" },
55
+ ]);
56
+
57
+ await testDb.insert(schema.sectionType).values([
58
+ { id: "READING", name: "Reading" },
59
+ ]);
60
+
61
+ const ownerId = "owner-id";
62
+ await testDb.insert(schema.user).values({ id: ownerId, name: "Owner", email: "owner@test.com" });
63
+ const [pkg] = await testDb.insert(schema.testPackage).values({
64
+ title: "IELTS Reading Test",
65
+ examTypeId: "IELTS",
66
+ creatorUserId: ownerId,
67
+ isPublic: true,
68
+ }).returning();
69
+ packageId = pkg.id;
70
+
71
+ const [sec] = await testDb.insert(schema.packageSection).values({
72
+ packageId: pkg.id,
73
+ sectionTypeId: "READING",
74
+ title: "Reading Section",
75
+ orderIndex: 0,
76
+ }).returning();
77
+
78
+ const qs = await testDb.insert(schema.question).values([
79
+ {
80
+ examTypeId: "IELTS", sectionTypeId: "READING", format: "multiple_choice",
81
+ passageText: "P".repeat(60), questionText: "What is the main idea?",
82
+ options: [{ key: "A", text: "Option A" }, { key: "B", text: "Option B" }, { key: "C", text: "Option C" }, { key: "D", text: "Option D" }],
83
+ correctAnswer: "A", explanation: "Because A is correct", difficulty: 2,
84
+ skillTags: ["main_idea"], creatorUserId: ownerId, isPublic: true,
85
+ },
86
+ {
87
+ examTypeId: "IELTS", sectionTypeId: "READING", format: "true_false_not_given",
88
+ passageText: "P".repeat(60), questionText: "The author states that...",
89
+ correctAnswer: "TRUE", explanation: "It says so", difficulty: 2,
90
+ skillTags: ["detail"], creatorUserId: ownerId, isPublic: true,
91
+ },
92
+ {
93
+ examTypeId: "IELTS", sectionTypeId: "READING", format: "fill_blank",
94
+ passageText: "P".repeat(60), questionText: "The answer is ___",
95
+ correctAnswer: "paradigm", explanation: "Context clue", difficulty: 3,
96
+ skillTags: ["vocabulary"], creatorUserId: ownerId, isPublic: true,
97
+ },
98
+ ]).returning();
99
+ questionIds = qs.map((q: any) => q.id);
100
+
101
+ await testDb.insert(schema.sectionQuestion).values([
102
+ { sectionId: sec.id, questionId: qs[0]!.id, orderIndex: 0 },
103
+ { sectionId: sec.id, questionId: qs[1]!.id, orderIndex: 1 },
104
+ { sectionId: sec.id, questionId: qs[2]!.id, orderIndex: 2 },
105
+ ]);
106
+ });
107
+
108
+ afterAll(async () => {
109
+ await closeTestPGlite();
110
+ });
111
+
112
+ it("start creates a new attempt", async () => {
113
+ const { caller } = await createUserAndCaller("start");
114
+ const result = await caller.start({ packageId });
115
+ expect(result).toHaveProperty("attemptId");
116
+ });
117
+
118
+ it("start rejects duplicate in-progress attempt", { timeout: 15000 }, async () => {
119
+ const { caller } = await createUserAndCaller("dup");
120
+ await caller.start({ packageId });
121
+ await Bun.sleep(3100); // wait for rate limit window (3000ms)
122
+ expect(caller.start({ packageId })).rejects.toThrow("sedang berjalan");
123
+ });
124
+
125
+ it("getById returns attempt with sections and questions", async () => {
126
+ const { caller } = await createUserAndCaller("getid");
127
+ const { attemptId } = await caller.start({ packageId });
128
+ const result = await caller.getById({ id: attemptId });
129
+ expect(result.status).toBe("in_progress");
130
+ expect(result.sections).toHaveLength(1);
131
+ expect(result.sections[0].questions).toHaveLength(3);
132
+ });
133
+
134
+ it("getById strips correctAnswer during in_progress", async () => {
135
+ const { caller } = await createUserAndCaller("strip");
136
+ const { attemptId } = await caller.start({ packageId });
137
+ const result = await caller.getById({ id: attemptId });
138
+ for (const s of result.sections) {
139
+ for (const q of s.questions) {
140
+ expect(q.correctAnswer).toBeUndefined();
141
+ expect(q.explanation).toBeUndefined();
142
+ }
143
+ }
144
+ });
145
+
146
+ it("submitAnswer stores user answer", async () => {
147
+ const { caller } = await createUserAndCaller("submit");
148
+ const { attemptId } = await caller.start({ packageId });
149
+ const attempt = await caller.getById({ id: attemptId });
150
+ const sectionResultId = attempt.sections[0].sectionResultId;
151
+
152
+ const result = await caller.submitAnswer({
153
+ attemptId, sectionResultId,
154
+ questionId: questionIds[0]!, userAnswer: "A",
155
+ });
156
+ expect(result.success).toBe(true);
157
+ });
158
+
159
+ it("finish scores correct answers", { timeout: 30000 }, async () => {
160
+ const { caller } = await createUserAndCaller("score");
161
+ const { attemptId } = await caller.start({ packageId });
162
+ await Bun.sleep(4500);
163
+ const attempt = await caller.getById({ id: attemptId });
164
+ const sectionResultId = attempt.sections[0].sectionResultId;
165
+
166
+ await caller.submitAnswer({ attemptId, sectionResultId, questionId: questionIds[0]!, userAnswer: "A" });
167
+ await Bun.sleep(600);
168
+ await caller.submitAnswer({ attemptId, sectionResultId, questionId: questionIds[1]!, userAnswer: "FALSE" });
169
+ await Bun.sleep(600);
170
+ await caller.submitAnswer({ attemptId, sectionResultId, questionId: questionIds[2]!, userAnswer: "wrong" });
171
+
172
+ const result = await caller.finish({ attemptId });
173
+ expect(result.totalScore).toBe(1);
174
+ expect(result.maxScore).toBe(3);
175
+ expect(result.percentage).toBe(33);
176
+ });
177
+
178
+ it("finish rejects attempts under 5 seconds", async () => {
179
+ const { caller } = await createUserAndCaller("fastfinish");
180
+ const { attemptId } = await caller.start({ packageId });
181
+ expect(caller.finish({ attemptId })).rejects.toThrow("terlalu cepat");
182
+ });
183
+
184
+ it("getActiveAttempt returns in-progress attempt", async () => {
185
+ const { caller } = await createUserAndCaller("active");
186
+ const { attemptId } = await caller.start({ packageId });
187
+ const result = await caller.getActiveAttempt({ packageId });
188
+ expect(result).not.toBeNull();
189
+ expect(result.id).toBe(attemptId);
190
+ expect(result.status).toBe("in_progress");
191
+ });
192
+
193
+ it("abandon marks attempt as abandoned", async () => {
194
+ const { caller } = await createUserAndCaller("abandon");
195
+ const { attemptId } = await caller.start({ packageId });
196
+ const result = await caller.abandon({ attemptId });
197
+ expect(result.success).toBe(true);
198
+ });
199
+
200
+ it("myAttempts returns paginated list", async () => {
201
+ const { caller } = await createUserAndCaller("mylist");
202
+ await caller.start({ packageId });
203
+ const result = await caller.myAttempts();
204
+ expect(result.attempts.length).toBeGreaterThan(0);
205
+ expect(result.total).toBeGreaterThan(0);
206
+ });
207
+ });
packages/api/src/__tests__/package.integration.test.ts ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, beforeAll, afterAll, mock } from "bun:test";
2
+ import { eq } from "drizzle-orm";
3
+ import { drizzle } from "drizzle-orm/pglite";
4
+ import * as schema from "../../../db/src/schema";
5
+ import { closeTestPGlite, getTestPGlite } from "./test-setup";
6
+
7
+ mock.module("@labas/env/server", () => ({
8
+ env: {
9
+ DATABASE_URL: "postgres://localhost:5432/test",
10
+ BETTER_AUTH_SECRET: "a".repeat(32),
11
+ BETTER_AUTH_URL: "http://localhost:3000",
12
+ CORS_ORIGIN: "http://localhost:5173",
13
+ API_KEY_ENCRYPTION_KEY: "z".repeat(32),
14
+ REDIS_URL: "redis://localhost:6379",
15
+ SMTP_HOST: "localhost",
16
+ SMTP_USER: "test",
17
+ SMTP_PASS: "test",
18
+ SMTP_FROM: "test@test.com",
19
+ },
20
+ }));
21
+
22
+ describe("package router", () => {
23
+ let packageRouter: any;
24
+ let caller: any;
25
+ let protectedCaller: any;
26
+ let testDb: any;
27
+ let ownerId: string;
28
+ let pkgId: string;
29
+ let sectionId: string;
30
+ let questionId: string;
31
+
32
+ beforeAll(async () => {
33
+ const pg = await getTestPGlite();
34
+ testDb = drizzle(pg, { schema });
35
+
36
+ mock.module("@labas/db", () => ({
37
+ __esModule: true,
38
+ ...schema,
39
+ db: testDb,
40
+ }));
41
+
42
+ await testDb.insert(schema.examType).values([
43
+ { id: "IELTS", name: "IELTS", language: "English" },
44
+ ]);
45
+ await testDb.insert(schema.sectionType).values([
46
+ { id: "READING", name: "Reading" },
47
+ { id: "WRITING", name: "Writing" },
48
+ ]);
49
+
50
+ ownerId = "pkg-owner";
51
+ await testDb.insert(schema.user).values({ id: ownerId, name: "Owner", email: "owner@test.com" });
52
+
53
+ const mod = await import("../routers/package");
54
+ packageRouter = mod.packageRouter;
55
+
56
+ caller = packageRouter.createCaller({ session: null, auth: null });
57
+ protectedCaller = packageRouter.createCaller({
58
+ session: { user: { id: ownerId }, expiresAt: new Date() },
59
+ auth: null,
60
+ });
61
+ });
62
+
63
+ afterAll(async () => {
64
+ await closeTestPGlite();
65
+ });
66
+
67
+ it("create makes a new package", async () => {
68
+ const result = await protectedCaller.create({
69
+ title: "My IELTS Pack",
70
+ examTypeId: "IELTS",
71
+ isPublic: false,
72
+ });
73
+ expect(result).toHaveProperty("id");
74
+ expect(result.title).toBe("My IELTS Pack");
75
+ pkgId = result.id;
76
+ });
77
+
78
+ it("getById returns package with sections", async () => {
79
+ const result = await protectedCaller.getById({ id: pkgId });
80
+ expect(result).not.toBeNull();
81
+ expect(result.title).toBe("My IELTS Pack");
82
+ expect(result.sections).toEqual([]);
83
+ });
84
+
85
+ it("getById returns null for non-existent", async () => {
86
+ const { randomUUID } = await import("node:crypto");
87
+ const result = await caller.getById({ id: randomUUID() });
88
+ expect(result).toBeNull();
89
+ });
90
+
91
+ it("list returns public packages", async () => {
92
+ // Make it public first
93
+ await testDb.update(schema.testPackage).set({ isPublic: true }).where(eq(schema.testPackage.id, pkgId));
94
+
95
+ const result = await caller.list();
96
+ expect(result.packages.length).toBeGreaterThan(0);
97
+ expect(result.packages[0].id).toBe(pkgId);
98
+
99
+ // Set back to private for upcoming tests
100
+ await testDb.update(schema.testPackage).set({ isPublic: false }).where(eq(schema.testPackage.id, pkgId));
101
+ });
102
+
103
+ it("list hides private packages from anonymous users", async () => {
104
+ const result = await caller.list();
105
+ const ids = result.packages.map((p: any) => p.id);
106
+ expect(ids).not.toContain(pkgId);
107
+ });
108
+
109
+ it("list shows private packages to owner via auth", async () => {
110
+ const result = await protectedCaller.list();
111
+ const ids = result.packages.map((p: any) => p.id);
112
+ expect(ids).toContain(pkgId);
113
+ });
114
+
115
+ it("list filters by examTypeId", async () => {
116
+ await testDb.update(schema.testPackage).set({ isPublic: true }).where(eq(schema.testPackage.id, pkgId));
117
+ const result = await caller.list({ examTypeId: "TOEFL" });
118
+ expect(result.packages).toHaveLength(0);
119
+ });
120
+
121
+ it("update changes package fields", async () => {
122
+ const result = await protectedCaller.update({
123
+ id: pkgId,
124
+ title: "Updated Pack",
125
+ });
126
+ expect(result.title).toBe("Updated Pack");
127
+ });
128
+
129
+ it("update rejects non-owner", async () => {
130
+ const otherCaller = packageRouter.createCaller({
131
+ session: { user: { id: "other-user" }, expiresAt: new Date() },
132
+ auth: null,
133
+ });
134
+ expect(otherCaller.update({ id: pkgId, title: "Hacked" })).rejects.toThrow("Forbidden");
135
+ });
136
+
137
+ it("addSection creates a section", async () => {
138
+ const result = await protectedCaller.addSection({
139
+ packageId: pkgId,
140
+ sectionTypeId: "READING",
141
+ title: "Reading Section",
142
+ orderIndex: 0,
143
+ });
144
+ expect(result).toHaveProperty("id");
145
+ sectionId = result.id;
146
+ });
147
+
148
+ it("addSection rejects non-owner", async () => {
149
+ const otherCaller = packageRouter.createCaller({
150
+ session: { user: { id: "other-user" }, expiresAt: new Date() },
151
+ auth: null,
152
+ });
153
+ expect(otherCaller.addSection({ packageId: pkgId, sectionTypeId: "READING", title: "X" })).rejects.toThrow("Forbidden");
154
+ });
155
+
156
+ it("getById includes sections after adding", async () => {
157
+ const result = await caller.getById({ id: pkgId });
158
+ expect(result.sections).toHaveLength(1);
159
+ expect(result.sections[0].sectionTypeId).toBe("READING");
160
+ });
161
+
162
+ it("addQuestion adds question to section", async () => {
163
+ const [q] = await testDb.insert(schema.question).values({
164
+ examTypeId: "IELTS", sectionTypeId: "READING", format: "multiple_choice",
165
+ passageText: "Q".repeat(60), questionText: "Test?",
166
+ options: [{ key: "A", text: "A" }, { key: "B", text: "B" }],
167
+ correctAnswer: "A", explanation: "Exp", difficulty: 1,
168
+ skillTags: ["test"], creatorUserId: ownerId, isPublic: true,
169
+ }).returning();
170
+ questionId = q.id;
171
+
172
+ const result = await protectedCaller.addQuestion({
173
+ sectionId,
174
+ questionId: q.id,
175
+ orderIndex: 0,
176
+ });
177
+ expect(result).toHaveProperty("id");
178
+ });
179
+
180
+ it("getById shows questions for owner, strips answers for guests", async () => {
181
+ const ownerResult = await protectedCaller.getById({ id: pkgId });
182
+ const section = ownerResult.sections.find((s: any) => s.id === sectionId);
183
+ expect(section.questions).toHaveLength(1);
184
+ expect(section.questions[0].correctAnswer).toBe("A");
185
+
186
+ const guestResult = await caller.getById({ id: pkgId });
187
+ const guestSection = guestResult.sections.find((s: any) => s.id === sectionId);
188
+ expect(guestSection.questions[0].correctAnswer).toBeUndefined();
189
+ });
190
+
191
+ it("myPackages returns owned packages", async () => {
192
+ const result = await protectedCaller.myPackages();
193
+ expect(result.packages.length).toBeGreaterThan(0);
194
+ const ids = result.packages.map((p: any) => p.id);
195
+ expect(ids).toContain(pkgId);
196
+ });
197
+
198
+ it("featured returns empty when no featured packages", async () => {
199
+ const result = await caller.featured();
200
+ expect(result).toEqual([]);
201
+ });
202
+
203
+ it("trending returns public packages sorted by usage", async () => {
204
+ await testDb.update(schema.testPackage).set({ isPublic: true }).where(eq(schema.testPackage.id, pkgId));
205
+ const result = await caller.trending();
206
+ expect(result.length).toBeGreaterThan(0);
207
+ });
208
+
209
+ it("removeQuestion removes question from section", async () => {
210
+ const sq = await protectedCaller.addQuestion({
211
+ sectionId, questionId, orderIndex: 0,
212
+ });
213
+ const result = await protectedCaller.removeQuestion({ sectionQuestionId: sq.id });
214
+ expect(result.success).toBe(true);
215
+ });
216
+
217
+ it("removeSection removes section", async () => {
218
+ const result = await protectedCaller.removeSection({ sectionId });
219
+ expect(result.success).toBe(true);
220
+ });
221
+
222
+ it("delete removes package", async () => {
223
+ const result = await protectedCaller.delete({ id: pkgId });
224
+ expect(result.success).toBe(true);
225
+
226
+ const fetched = await caller.getById({ id: pkgId });
227
+ expect(fetched).toBeNull();
228
+ });
229
+ });
packages/api/src/__tests__/question.integration.test.ts ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, beforeAll, afterAll, mock } from "bun:test";
2
+ import { drizzle } from "drizzle-orm/pglite";
3
+ import * as schema from "../../../db/src/schema";
4
+ import { closeTestPGlite, getTestPGlite, createTestUserData } from "./test-setup";
5
+
6
+ mock.module("@labas/env/server", () => ({
7
+ env: {
8
+ DATABASE_URL: "postgres://localhost:5432/test",
9
+ BETTER_AUTH_SECRET: "a".repeat(32),
10
+ BETTER_AUTH_URL: "http://localhost:3000",
11
+ CORS_ORIGIN: "http://localhost:5173",
12
+ API_KEY_ENCRYPTION_KEY: "z".repeat(32),
13
+ REDIS_URL: "redis://localhost:6379",
14
+ SMTP_HOST: "localhost",
15
+ SMTP_USER: "test",
16
+ SMTP_PASS: "test",
17
+ SMTP_FROM: "test@test.com",
18
+ },
19
+ }));
20
+
21
+ describe("question router", () => {
22
+ let questionRouter: any;
23
+ let user1: any;
24
+
25
+ beforeAll(async () => {
26
+ const pg = await getTestPGlite();
27
+ const testDb = drizzle(pg, { schema });
28
+
29
+ mock.module("@labas/db", () => ({
30
+ __esModule: true,
31
+ ...schema,
32
+ db: testDb,
33
+ }));
34
+
35
+ const data = await createTestUserData();
36
+ user1 = data.user1;
37
+
38
+ const mod = await import("../routers/question");
39
+ questionRouter = mod.questionRouter;
40
+ });
41
+
42
+ afterAll(async () => {
43
+ await closeTestPGlite();
44
+ });
45
+
46
+ let caller: any;
47
+ let protectedCaller: any;
48
+
49
+ beforeAll(async () => {
50
+ caller = questionRouter.createCaller({ session: null, auth: null });
51
+ protectedCaller = questionRouter.createCaller({
52
+ session: { user: { id: user1.id }, expiresAt: new Date() },
53
+ auth: null,
54
+ });
55
+ });
56
+
57
+ it("list returns public questions", async () => {
58
+ const result = await caller.list();
59
+ expect(result.questions).toHaveLength(1);
60
+ expect(result.questions[0]).not.toHaveProperty("correctAnswer");
61
+ expect(result.total).toBe(1);
62
+ });
63
+
64
+ it("list filters by examTypeId", async () => {
65
+ const result = await caller.list({ examTypeId: "IELTS" });
66
+ expect(result.questions).toHaveLength(1);
67
+ });
68
+
69
+ it("list returns empty when no match", async () => {
70
+ const result = await caller.list({ examTypeId: "TOEFL" });
71
+ expect(result.questions).toHaveLength(0);
72
+ });
73
+
74
+ it("getById returns null for non-existent question", async () => {
75
+ const { randomUUID } = await import("node:crypto");
76
+ const result = await caller.getById({ id: randomUUID() });
77
+ expect(result).toBeNull();
78
+ });
79
+
80
+ it("create inserts a new question", async () => {
81
+ const result = await protectedCaller.create({
82
+ examTypeId: "IELTS",
83
+ sectionTypeId: "READING",
84
+ format: "fill_blank",
85
+ passageText: "B".repeat(60),
86
+ questionText: "What is the answer?",
87
+ correctAnswer: "test answer",
88
+ difficulty: 2,
89
+ isCaseSensitive: false,
90
+ skillTags: ["vocabulary"],
91
+ isPublic: false,
92
+ });
93
+ expect(result).toHaveProperty("id");
94
+ expect(result.format).toBe("fill_blank");
95
+ });
96
+ });
packages/api/src/__tests__/queue.test.ts ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, beforeAll } from "bun:test";
2
+
3
+ let GenerationJobCancelledError: any,
4
+ computeSectionSplit: any,
5
+ splitIntoShards: any,
6
+ normalizeQuestions: any,
7
+ runWithConcurrency: any,
8
+ decryptInputFromDb: any;
9
+
10
+ beforeAll(async () => {
11
+ process.env.DATABASE_URL = "postgres://localhost:5432/test";
12
+ process.env.BETTER_AUTH_SECRET = "a".repeat(32);
13
+ process.env.BETTER_AUTH_URL = "http://localhost:3000";
14
+ process.env.CORS_ORIGIN = "http://localhost:5173";
15
+ process.env.API_KEY_ENCRYPTION_KEY = "z".repeat(32);
16
+ process.env.REDIS_URL = "redis://localhost:6379";
17
+ process.env.SMTP_HOST = "localhost";
18
+ process.env.SMTP_USER = "test";
19
+ process.env.SMTP_PASS = "test";
20
+ process.env.SMTP_FROM = "test@test.com";
21
+
22
+ const mod = await import("../queue");
23
+ GenerationJobCancelledError = mod.GenerationJobCancelledError;
24
+ computeSectionSplit = mod.computeSectionSplit;
25
+ splitIntoShards = mod.splitIntoShards;
26
+ normalizeQuestions = mod.normalizeQuestions;
27
+ runWithConcurrency = mod.runWithConcurrency;
28
+ decryptInputFromDb = mod.decryptInputFromDb;
29
+ });
30
+
31
+ describe("GenerationJobCancelledError", () => {
32
+ it("creates error with correct name", () => {
33
+ const err = new GenerationJobCancelledError();
34
+ expect(err).toBeInstanceOf(Error);
35
+ expect(err.name).toBe("GenerationJobCancelledError");
36
+ expect(err.message).toBe("JOB_CANCELLED");
37
+ });
38
+ });
39
+
40
+ describe("computeSectionSplit", () => {
41
+ it("returns single section when no sections selected", () => {
42
+ const result = computeSectionSplit([], 10);
43
+ expect(result).toEqual([{ section: "READING", count: 10 }]);
44
+ });
45
+
46
+ it("returns single section when count < 20 even with multiple sections", () => {
47
+ const result = computeSectionSplit(["READING", "LISTENING"], 15);
48
+ expect(result).toEqual([{ section: "READING", count: 15 }]);
49
+ });
50
+
51
+ it("distributes evenly when count >= 20 and multiple sections", () => {
52
+ const result = computeSectionSplit(["READING", "LISTENING"], 20);
53
+ expect(result).toHaveLength(2);
54
+ expect(result[0]!).toEqual({ section: "READING", count: 10 });
55
+ expect(result[1]!).toEqual({ section: "LISTENING", count: 10 });
56
+ });
57
+
58
+ it("distributes remainder (first section gets extra)", () => {
59
+ const result = computeSectionSplit(["READING", "LISTENING", "WRITING"], 22);
60
+ expect(result).toHaveLength(3);
61
+ expect(result[0]!).toEqual({ section: "READING", count: 8 }); // 7 + 1
62
+ expect(result[1]!).toEqual({ section: "LISTENING", count: 7 }); // 7 + 1
63
+ expect(result[2]!).toEqual({ section: "WRITING", count: 7 }); // 7
64
+ });
65
+
66
+ it("handles single section with high count", () => {
67
+ const result = computeSectionSplit(["READING"], 40);
68
+ expect(result).toEqual([{ section: "READING", count: 40 }]);
69
+ });
70
+ });
71
+
72
+ describe("splitIntoShards", () => {
73
+ it("creates one shard when count <= max per shard", () => {
74
+ const result = splitIntoShards([{ section: "READING", count: 5 }]);
75
+ expect(result).toHaveLength(1);
76
+ expect(result[0]!).toMatchObject({
77
+ section: "READING", count: 5, sectionIndex: 0, shardIndex: 0, shardCount: 1,
78
+ });
79
+ });
80
+
81
+ it("splits into multiple shards when count exceeds max", () => {
82
+ const result = splitIntoShards([{ section: "READING", count: 20 }]);
83
+ expect(result).toHaveLength(4); // ceil(20/6) = 4
84
+ expect(result[0]!.count).toBe(6);
85
+ expect(result[3]!.count).toBe(2); // last shard has remainder
86
+ result.forEach((shard) => {
87
+ expect(shard.section).toBe("READING");
88
+ expect(shard.shardCount).toBe(4);
89
+ });
90
+ });
91
+
92
+ it("handles multiple sections with sharding", () => {
93
+ const result = splitIntoShards([
94
+ { section: "READING", count: 10 },
95
+ { section: "LISTENING", count: 10 },
96
+ ]);
97
+ expect(result).toHaveLength(4); // ceil(10/6) + ceil(10/6) = 2 + 2
98
+ expect(result[0]!.section).toBe("READING");
99
+ expect(result[0]!.sectionIndex).toBe(0);
100
+ expect(result[2]!.section).toBe("LISTENING");
101
+ expect(result[2]!.sectionIndex).toBe(1);
102
+ });
103
+ });
104
+
105
+ describe("normalizeQuestions", () => {
106
+ const model = "gpt-4";
107
+
108
+ it("transforms GenerationResult questions to PersistableQuestion", () => {
109
+ const result = {
110
+ questions: [{
111
+ format: "multiple_choice",
112
+ passageText: "A".repeat(50),
113
+ questionText: "Question?",
114
+ options: [{ key: "A", text: "Opt" }],
115
+ correctAnswer: "A",
116
+ explanation: "Because",
117
+ difficulty: 3,
118
+ skillTags: ["reading"],
119
+ }],
120
+ meta: { model, tokensUsed: 100, durationMs: 1000, mode: "quick" as const },
121
+ };
122
+
123
+ const normalized = normalizeQuestions("READING", model, result as any);
124
+ expect(normalized).toHaveLength(1);
125
+ expect(normalized[0]!).toMatchObject({
126
+ section: "READING",
127
+ format: "multiple_choice",
128
+ correctAnswer: "A",
129
+ difficulty: 3,
130
+ aiModel: model,
131
+ });
132
+ });
133
+
134
+ it("sets options to null when not present", () => {
135
+ const result = {
136
+ questions: [{
137
+ format: "fill_blank",
138
+ passageText: "A".repeat(50),
139
+ questionText: "Fill ___",
140
+ correctAnswer: "answer",
141
+ explanation: "explain",
142
+ difficulty: 2,
143
+ skillTags: ["grammar"],
144
+ }],
145
+ meta: { model, tokensUsed: 50, durationMs: 500, mode: "quick" as const },
146
+ };
147
+
148
+ const normalized = normalizeQuestions("READING", model, result as any);
149
+ expect(normalized[0]!.options).toBeNull();
150
+ });
151
+
152
+ it("turns off isCaseSensitive on correctAnswer when caseSensitive is false", () => {
153
+ const result = {
154
+ questions: [{
155
+ isCaseSensitive: false,
156
+ format: "fill_blank",
157
+ passageText: "A".repeat(50),
158
+ questionText: "Fill ___",
159
+ correctAnswer: "Answer",
160
+ explanation: "explain",
161
+ difficulty: 2,
162
+ skillTags: ["grammar"],
163
+ }],
164
+ meta: { model, tokensUsed: 50, durationMs: 500, mode: "quick" as const },
165
+ };
166
+
167
+ const normalized = normalizeQuestions("READING", model, result as any);
168
+ expect(normalized[0]!.correctAnswer).toBe("Answer");
169
+ });
170
+ });
171
+
172
+ describe("runWithConcurrency", () => {
173
+ it("processes all items with concurrency", async () => {
174
+ const items = [1, 2, 3, 4, 5];
175
+ const results = await runWithConcurrency(items, 3, async (item) => item * 2);
176
+ expect(results).toEqual([2, 4, 6, 8, 10]);
177
+ });
178
+
179
+ it("maintains order of results", async () => {
180
+ const items = ["a", "b", "c"];
181
+ const results = await runWithConcurrency(items, 2, async (item, idx) => `${item}-${idx}`);
182
+ expect(results).toEqual(["a-0", "b-1", "c-2"]);
183
+ });
184
+
185
+ it("handles empty array", async () => {
186
+ const results = await runWithConcurrency([], 3, async (item) => item);
187
+ expect(results).toEqual([]);
188
+ });
189
+
190
+ it("propagates errors from worker", async () => {
191
+ const items = [1, 2, 3];
192
+ expect(
193
+ runWithConcurrency(items, 2, async (item) => {
194
+ if (item === 2) throw new Error("Item 2 failed");
195
+ return item;
196
+ }),
197
+ ).rejects.toThrow("Item 2 failed");
198
+ });
199
+ });
200
+
201
+ describe("decryptInputFromDb", () => {
202
+ it("passes through unencrypted apiKey (legacy data)", () => {
203
+ const input = {
204
+ examType: "IELTS",
205
+ section: "READING",
206
+ apiKeyConfig: {
207
+ baseUrl: "https://api.openai.com/v1",
208
+ apiKey: "sk-legacy-key",
209
+ model: "gpt-4",
210
+ },
211
+ };
212
+ const result = decryptInputFromDb(input as any);
213
+ expect(result.apiKeyConfig.apiKey).toBe("sk-legacy-key");
214
+ });
215
+
216
+ it("handles missing apiKey in config", () => {
217
+ const input = {
218
+ examType: "IELTS",
219
+ section: "READING",
220
+ apiKeyConfig: {
221
+ baseUrl: "https://api.openai.com/v1",
222
+ model: "gpt-4",
223
+ },
224
+ };
225
+ const result = decryptInputFromDb(input as any);
226
+ expect(result.apiKeyConfig.apiKey).toBeUndefined();
227
+ });
228
+ });
packages/api/src/__tests__/test-db.ts ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PGlite } from "@electric-sql/pglite";
2
+ import { drizzle } from "drizzle-orm/pglite";
3
+ import * as schema from "@labas/db";
4
+
5
+ let _client: PGlite | null = null;
6
+
7
+ export async function createTestDb() {
8
+ if (_client) {
9
+ await _client.close();
10
+ }
11
+ _client = new PGlite();
12
+ const db = drizzle(_client, { schema });
13
+
14
+ // Enable pgcrypto for gen_random_uuid()
15
+ await _client.exec("CREATE EXTENSION IF NOT EXISTS pgcrypto");
16
+
17
+ // Create tables from Drizzle schema
18
+ // We use raw SQL since drizzle push needs drizzle-kit
19
+ const tables = [
20
+ `CREATE TABLE IF NOT EXISTS "user" (id text PRIMARY KEY, name text NOT NULL, email text NOT NULL UNIQUE, email_verified boolean DEFAULT false NOT NULL, image text, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
21
+ `CREATE TABLE IF NOT EXISTS "session" (id text PRIMARY KEY, expires_at timestamp NOT NULL, token text NOT NULL UNIQUE, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL, ip_address text, user_agent text, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE)`,
22
+ `CREATE TABLE IF NOT EXISTS "account" (id text PRIMARY KEY, account_id text NOT NULL, provider_id text NOT NULL, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, access_token text, refresh_token text, id_token text, access_token_expires_at timestamp, refresh_token_expires_at timestamp, scope text, password text, created_at timestamp DEFAULT now() NOT NULL)`,
23
+ `CREATE TABLE IF NOT EXISTS "verification" (id text PRIMARY KEY, identifier text NOT NULL, value text NOT NULL, expires_at timestamp NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
24
+ `CREATE TABLE IF NOT EXISTS "exam_type" (id text PRIMARY KEY, name text NOT NULL, language text NOT NULL, description text)`,
25
+ `CREATE TABLE IF NOT EXISTS "section_type" (id text PRIMARY KEY, name text NOT NULL)`,
26
+ `CREATE TABLE IF NOT EXISTS "question" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, exam_type_id text NOT NULL REFERENCES exam_type(id) ON DELETE CASCADE, section_type_id text NOT NULL REFERENCES section_type(id) ON DELETE CASCADE, format text NOT NULL, passage_text text NOT NULL, question_text text NOT NULL, options jsonb, correct_answer text NOT NULL, explanation text, difficulty integer DEFAULT 3 NOT NULL, skill_tags text[] DEFAULT '{}', is_case_sensitive boolean DEFAULT false NOT NULL, source text DEFAULT 'manual' NOT NULL, ai_model text, ai_prompt_used text, creator_user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, is_public boolean DEFAULT false NOT NULL, usage_count integer DEFAULT 0 NOT NULL, avg_rating integer, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
27
+ `CREATE TABLE IF NOT EXISTS "test_package" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, title text NOT NULL, description text, exam_type_id text NOT NULL REFERENCES exam_type(id) ON DELETE CASCADE, creator_user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, is_public boolean DEFAULT false NOT NULL, total_questions integer DEFAULT 0 NOT NULL, total_sections integer DEFAULT 0 NOT NULL, estimated_duration_min integer, usage_count integer DEFAULT 0 NOT NULL, avg_rating integer, is_featured boolean DEFAULT false NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
28
+ `CREATE TABLE IF NOT EXISTS "package_section" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, package_id uuid NOT NULL REFERENCES test_package(id) ON DELETE CASCADE, section_type_id text NOT NULL REFERENCES section_type(id) ON DELETE CASCADE, title text NOT NULL, order_index integer DEFAULT 0 NOT NULL, created_at timestamp DEFAULT now() NOT NULL)`,
29
+ `CREATE TABLE IF NOT EXISTS "section_question" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, section_id uuid NOT NULL REFERENCES package_section(id) ON DELETE CASCADE, question_id uuid NOT NULL REFERENCES question(id) ON DELETE CASCADE, order_index integer DEFAULT 0 NOT NULL)`,
30
+ `CREATE TABLE IF NOT EXISTS "test_attempt" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, package_id uuid REFERENCES test_package(id) ON DELETE SET NULL, combo_id uuid, started_at timestamp DEFAULT now() NOT NULL, finished_at timestamp, total_score integer, max_score integer, status text DEFAULT 'in_progress' NOT NULL, created_at timestamp DEFAULT now() NOT NULL)`,
31
+ `CREATE TABLE IF NOT EXISTS "section_result" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, attempt_id uuid NOT NULL REFERENCES test_attempt(id) ON DELETE CASCADE, section_type_id text NOT NULL REFERENCES section_type(id) ON DELETE CASCADE, score integer, max_score integer, time_spent_sec integer, created_at timestamp DEFAULT now() NOT NULL)`,
32
+ `CREATE TABLE IF NOT EXISTS "answer" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, section_result_id uuid NOT NULL REFERENCES section_result(id) ON DELETE CASCADE, question_id uuid NOT NULL REFERENCES question(id) ON DELETE CASCADE, user_answer text, is_correct boolean, partial_score integer, time_spent_sec integer, created_at timestamp DEFAULT now() NOT NULL)`,
33
+ ];
34
+
35
+ for (const sql of tables) {
36
+ await _client.exec(sql);
37
+ }
38
+
39
+ return db;
40
+ }
41
+
42
+ export async function seedTestData(db: ReturnType<typeof drizzle>) {
43
+ await db.insert(schema.examType).values([
44
+ { id: "IELTS", name: "IELTS", language: "English" },
45
+ { id: "TOEFL", name: "TOEFL", language: "English" },
46
+ ]);
47
+
48
+ await db.insert(schema.sectionType).values([
49
+ { id: "READING", name: "Reading" },
50
+ { id: "WRITING", name: "Writing" },
51
+ { id: "LISTENING", name: "Listening" },
52
+ ]);
53
+
54
+ const [testUser] = await db
55
+ .insert(schema.user)
56
+ .values({ id: "user-1", name: "Test User", email: "test@test.com" })
57
+ .returning();
58
+
59
+ const [otherUser] = await db
60
+ .insert(schema.user)
61
+ .values({ id: "user-2", name: "Other User", email: "other@test.com" })
62
+ .returning();
63
+
64
+ return { testUser, otherUser };
65
+ }
packages/api/src/__tests__/test-setup.ts ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PGlite } from "@electric-sql/pglite";
2
+ import { drizzle } from "drizzle-orm/pglite";
3
+ import * as schema from "../../../db/src/schema";
4
+
5
+ let _pg: PGlite | null = null;
6
+
7
+ export async function getTestPGlite() {
8
+ if (!_pg) {
9
+ _pg = new PGlite();
10
+ await initSchema(_pg);
11
+ }
12
+ return _pg;
13
+ }
14
+
15
+ export async function closeTestPGlite() {
16
+ if (_pg) {
17
+ await _pg.close();
18
+ _pg = null;
19
+ }
20
+ }
21
+
22
+ async function initSchema(pg: PGlite) {
23
+ const tables = [
24
+ `CREATE TABLE IF NOT EXISTS "user" (id text PRIMARY KEY, name text NOT NULL, email text NOT NULL UNIQUE, email_verified boolean DEFAULT false NOT NULL, image text, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
25
+ `CREATE TABLE IF NOT EXISTS "session" (id text PRIMARY KEY, expires_at timestamp NOT NULL, token text NOT NULL UNIQUE, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL, ip_address text, user_agent text, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE)`,
26
+ `CREATE TABLE IF NOT EXISTS "account" (id text PRIMARY KEY, account_id text NOT NULL, provider_id text NOT NULL, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, access_token text, refresh_token text, id_token text, access_token_expires_at timestamp, refresh_token_expires_at timestamp, scope text, password text, created_at timestamp DEFAULT now() NOT NULL)`,
27
+ `CREATE TABLE IF NOT EXISTS "verification" (id text PRIMARY KEY, identifier text NOT NULL, value text NOT NULL, expires_at timestamp NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
28
+ `CREATE TABLE IF NOT EXISTS "exam_type" (id text PRIMARY KEY, name text NOT NULL, language text NOT NULL, description text)`,
29
+ `CREATE TABLE IF NOT EXISTS "section_type" (id text PRIMARY KEY, name text NOT NULL)`,
30
+ `CREATE TABLE IF NOT EXISTS "question" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, exam_type_id text NOT NULL REFERENCES exam_type(id) ON DELETE CASCADE, section_type_id text NOT NULL REFERENCES section_type(id) ON DELETE CASCADE, format text NOT NULL, passage_text text NOT NULL, question_text text NOT NULL, options jsonb, correct_answer text NOT NULL, explanation text, difficulty integer DEFAULT 3 NOT NULL, skill_tags text[] DEFAULT '{}', is_case_sensitive boolean DEFAULT false NOT NULL, source text DEFAULT 'manual' NOT NULL, ai_model text, ai_prompt_used text, creator_user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, is_public boolean DEFAULT false NOT NULL, usage_count integer DEFAULT 0 NOT NULL, avg_rating integer, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
31
+ `CREATE TABLE IF NOT EXISTS "test_package" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, title text NOT NULL, description text, exam_type_id text NOT NULL REFERENCES exam_type(id) ON DELETE CASCADE, creator_user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, is_public boolean DEFAULT false NOT NULL, total_questions integer DEFAULT 0 NOT NULL, total_sections integer DEFAULT 0 NOT NULL, estimated_duration_min integer, usage_count integer DEFAULT 0 NOT NULL, avg_rating integer, is_featured boolean DEFAULT false NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
32
+ `CREATE TABLE IF NOT EXISTS "package_section" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, package_id uuid NOT NULL REFERENCES test_package(id) ON DELETE CASCADE, section_type_id text NOT NULL REFERENCES section_type(id) ON DELETE CASCADE, title text NOT NULL, order_index integer DEFAULT 0 NOT NULL, created_at timestamp DEFAULT now() NOT NULL)`,
33
+ `CREATE TABLE IF NOT EXISTS "section_question" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, section_id uuid NOT NULL REFERENCES package_section(id) ON DELETE CASCADE, question_id uuid NOT NULL REFERENCES question(id) ON DELETE CASCADE, order_index integer DEFAULT 0 NOT NULL)`,
34
+ `CREATE TABLE IF NOT EXISTS "test_attempt" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, package_id uuid REFERENCES test_package(id) ON DELETE SET NULL, combo_id uuid, started_at timestamp DEFAULT now() NOT NULL, finished_at timestamp, total_score integer, max_score integer, status text DEFAULT 'in_progress' NOT NULL, created_at timestamp DEFAULT now() NOT NULL)`,
35
+ `CREATE TABLE IF NOT EXISTS "section_result" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, attempt_id uuid NOT NULL REFERENCES test_attempt(id) ON DELETE CASCADE, section_type_id text NOT NULL REFERENCES section_type(id) ON DELETE CASCADE, score integer, max_score integer, time_spent_sec integer, created_at timestamp DEFAULT now() NOT NULL)`,
36
+ `CREATE TABLE IF NOT EXISTS "answer" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, section_result_id uuid NOT NULL REFERENCES section_result(id) ON DELETE CASCADE, question_id uuid NOT NULL REFERENCES question(id) ON DELETE CASCADE, user_answer text, is_correct boolean, partial_score integer, time_spent_sec integer, created_at timestamp DEFAULT now() NOT NULL)`,
37
+ ];
38
+
39
+ for (const sql of tables) {
40
+ await pg.exec(sql);
41
+ }
42
+ }
43
+
44
+ export async function createTestUserData() {
45
+ const pg = await getTestPGlite();
46
+ const db = drizzle(pg, { schema });
47
+
48
+ const [user1] = await db.insert(schema.user).values({
49
+ id: "user-1", name: "Test User", email: "test@test.com",
50
+ }).returning();
51
+
52
+ const [user2] = await db.insert(schema.user).values({
53
+ id: "user-2", name: "Other User", email: "other@test.com",
54
+ }).returning();
55
+
56
+ await db.insert(schema.examType).values([
57
+ { id: "IELTS", name: "IELTS", language: "English" },
58
+ { id: "TOEFL", name: "TOEFL", language: "English" },
59
+ ]);
60
+
61
+ await db.insert(schema.sectionType).values([
62
+ { id: "READING", name: "Reading" },
63
+ { id: "WRITING", name: "Writing" },
64
+ { id: "LISTENING", name: "Listening" },
65
+ ]);
66
+
67
+ await db.insert(schema.question).values([
68
+ {
69
+ examTypeId: "IELTS", sectionTypeId: "READING", format: "multiple_choice",
70
+ passageText: "A".repeat(100), questionText: "What is X?",
71
+ options: [{ key: "A", text: "Opt A" }, { key: "B", text: "Opt B" }],
72
+ correctAnswer: "A", explanation: "Test", difficulty: 3,
73
+ skillTags: ["comprehension"], creatorUserId: user1.id, isPublic: true,
74
+ },
75
+ ]);
76
+
77
+ await db.insert(schema.testPackage).values({
78
+ id: "00000000-0000-0000-0000-000000000001",
79
+ title: "Test Package", examTypeId: "IELTS",
80
+ creatorUserId: user1.id, isPublic: true,
81
+ });
82
+
83
+ return { user1, user2 };
84
+ }
packages/api/src/lib/__tests__/encryption.test.ts ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, beforeAll } from "bun:test";
2
+ import { encryptApiKey, decryptApiKey } from "../encryption";
3
+
4
+ const TEST_KEY = "test-encryption-key-1234567890abcd"; // 32+ chars
5
+ const TEST_PLAINTEXT = "sk-my-secret-api-key-abc123";
6
+
7
+ beforeAll(() => {
8
+ process.env.API_KEY_ENCRYPTION_KEY = TEST_KEY;
9
+ });
10
+
11
+ describe("encryptApiKey / decryptApiKey", () => {
12
+ it("round-trips a plaintext successfully", () => {
13
+ const encrypted = encryptApiKey(TEST_PLAINTEXT);
14
+ expect(encrypted).toBeTruthy();
15
+ expect(typeof encrypted).toBe("string");
16
+
17
+ const decrypted = decryptApiKey(encrypted);
18
+ expect(decrypted).toBe(TEST_PLAINTEXT);
19
+ });
20
+
21
+ it("produces different ciphertext each call (uses random salt/iv)", () => {
22
+ const result1 = encryptApiKey(TEST_PLAINTEXT);
23
+ const result2 = encryptApiKey(TEST_PLAINTEXT);
24
+ expect(result1).not.toBe(result2);
25
+ });
26
+
27
+ it("handles empty string", () => {
28
+ const encrypted = encryptApiKey("");
29
+ const decrypted = decryptApiKey(encrypted);
30
+ expect(decrypted).toBe("");
31
+ });
32
+
33
+ it("handles special characters", () => {
34
+ const special = "abc123!@#$%^&*()_+-=[]{}|;':\",./<>?`~你好日本語";
35
+ const encrypted = encryptApiKey(special);
36
+ const decrypted = decryptApiKey(encrypted);
37
+ expect(decrypted).toBe(special);
38
+ });
39
+ });
40
+
41
+ describe("decryptApiKey — error handling", () => {
42
+ it("throws on invalid format (not 4 parts)", () => {
43
+ expect(() => decryptApiKey("invalid-format")).toThrow("Invalid encrypted API key format");
44
+ });
45
+
46
+ it("throws on tampered ciphertext", () => {
47
+ const encrypted = encryptApiKey(TEST_PLAINTEXT);
48
+ const parts = encrypted.split(":");
49
+ // Tamper the ciphertext part
50
+ parts[3] = "tampered-data";
51
+ const tampered = parts.join(":");
52
+ expect(() => decryptApiKey(tampered)).toThrow();
53
+ });
54
+ });
packages/api/src/lib/__tests__/errors.test.ts ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "bun:test";
2
+ import { TRPCError } from "@trpc/server";
3
+ import {
4
+ throwUnauthorized,
5
+ throwForbidden,
6
+ throwNotFound,
7
+ throwBadRequest,
8
+ throwInternal,
9
+ } from "../errors";
10
+
11
+ function expectThrowsTRPCError(fn: () => never, code: string, message: string) {
12
+ try {
13
+ fn();
14
+ expect.unreachable("Should have thrown");
15
+ } catch (err) {
16
+ expect(err).toBeInstanceOf(TRPCError);
17
+ const trpcErr = err as TRPCError;
18
+ expect(trpcErr.code).toBe(code);
19
+ expect(trpcErr.message).toBe(message);
20
+ }
21
+ }
22
+
23
+ describe("throwUnauthorized", () => {
24
+ it("throws UNAUTHORIZED with default message", () => {
25
+ expectThrowsTRPCError(throwUnauthorized, "UNAUTHORIZED", "Unauthorized");
26
+ });
27
+
28
+ it("throws UNAUTHORIZED with custom message", () => {
29
+ expectThrowsTRPCError(() => throwUnauthorized("Custom message"), "UNAUTHORIZED", "Custom message");
30
+ });
31
+ });
32
+
33
+ describe("throwForbidden", () => {
34
+ it("throws FORBIDDEN with default message", () => {
35
+ expectThrowsTRPCError(throwForbidden, "FORBIDDEN", "Forbidden");
36
+ });
37
+
38
+ it("throws FORBIDDEN with custom message", () => {
39
+ expectThrowsTRPCError(() => throwForbidden("Access denied"), "FORBIDDEN", "Access denied");
40
+ });
41
+ });
42
+
43
+ describe("throwNotFound", () => {
44
+ it("throws NOT_FOUND with default resource message", () => {
45
+ expectThrowsTRPCError(throwNotFound, "NOT_FOUND", "Resource not found");
46
+ });
47
+
48
+ it("throws NOT_FOUND with custom resource name", () => {
49
+ expectThrowsTRPCError(() => throwNotFound("Question"), "NOT_FOUND", "Question not found");
50
+ });
51
+ });
52
+
53
+ describe("throwBadRequest", () => {
54
+ it("throws BAD_REQUEST with default message", () => {
55
+ expectThrowsTRPCError(throwBadRequest, "BAD_REQUEST", "Bad request");
56
+ });
57
+
58
+ it("throws BAD_REQUEST with custom message", () => {
59
+ expectThrowsTRPCError(() => throwBadRequest("Invalid input"), "BAD_REQUEST", "Invalid input");
60
+ });
61
+ });
62
+
63
+ describe("throwInternal", () => {
64
+ it("throws INTERNAL_SERVER_ERROR with default message", () => {
65
+ expectThrowsTRPCError(throwInternal, "INTERNAL_SERVER_ERROR", "Internal server error");
66
+ });
67
+
68
+ it("throws INTERNAL_SERVER_ERROR with custom message", () => {
69
+ expectThrowsTRPCError(() => throwInternal("DB connection failed"), "INTERNAL_SERVER_ERROR", "DB connection failed");
70
+ });
71
+ });
packages/api/src/lib/__tests__/ownership.test.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "bun:test";
2
+ import { TRPCError } from "@trpc/server";
3
+ import { assertOwnership } from "../ownership";
4
+
5
+ describe("assertOwnership", () => {
6
+ const userId = "user-123";
7
+
8
+ it("throws NOT_FOUND when row is null", () => {
9
+ try {
10
+ assertOwnership(null, userId);
11
+ expect.unreachable("Should have thrown");
12
+ } catch (err) {
13
+ expect(err).toBeInstanceOf(TRPCError);
14
+ expect((err as TRPCError).code).toBe("NOT_FOUND");
15
+ expect((err as TRPCError).message).toBe("Resource not found");
16
+ }
17
+ });
18
+
19
+ it("throws NOT_FOUND when row is undefined", () => {
20
+ try {
21
+ assertOwnership(undefined, userId);
22
+ expect.unreachable("Should have thrown");
23
+ } catch (err) {
24
+ expect(err).toBeInstanceOf(TRPCError);
25
+ expect((err as TRPCError).code).toBe("NOT_FOUND");
26
+ }
27
+ });
28
+
29
+ it("throws NOT_FOUND with custom resource name", () => {
30
+ try {
31
+ assertOwnership(null, userId, "Question");
32
+ expect.unreachable("Should have thrown");
33
+ } catch (err) {
34
+ expect(err).toBeInstanceOf(TRPCError);
35
+ expect((err as TRPCError).code).toBe("NOT_FOUND");
36
+ expect((err as TRPCError).message).toBe("Question not found");
37
+ }
38
+ });
39
+
40
+ it("throws FORBIDDEN when creatorUserId does not match", () => {
41
+ try {
42
+ assertOwnership({ creatorUserId: "other-user" }, userId);
43
+ expect.unreachable("Should have thrown");
44
+ } catch (err) {
45
+ expect(err).toBeInstanceOf(TRPCError);
46
+ expect((err as TRPCError).code).toBe("FORBIDDEN");
47
+ }
48
+ });
49
+
50
+ it("throws FORBIDDEN when creatorUserId is null and userId is provided", () => {
51
+ try {
52
+ assertOwnership({ creatorUserId: null }, userId);
53
+ expect.unreachable("Should have thrown");
54
+ } catch (err) {
55
+ expect(err).toBeInstanceOf(TRPCError);
56
+ expect((err as TRPCError).code).toBe("FORBIDDEN");
57
+ }
58
+ });
59
+
60
+ it("does not throw when creatorUserId matches userId", () => {
61
+ expect(() => assertOwnership({ creatorUserId: userId }, userId)).not.toThrow();
62
+ });
63
+ });
packages/api/src/lib/__tests__/pagination.test.ts ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "bun:test";
2
+ import { SQL } from "drizzle-orm";
3
+ import { paginationSchema, paginateDefaults, countSql } from "../pagination";
4
+
5
+ describe("paginationSchema", () => {
6
+ it("parses valid input with defaults", () => {
7
+ const result = paginationSchema.parse({});
8
+ expect(result).toEqual({ limit: 20, offset: 0 });
9
+ });
10
+
11
+ it("parses with custom limit and offset", () => {
12
+ const result = paginationSchema.parse({ limit: 10, offset: 5 });
13
+ expect(result).toEqual({ limit: 10, offset: 5 });
14
+ });
15
+
16
+ it("rejects limit below minimum", () => {
17
+ expect(() => paginationSchema.parse({ limit: 0 })).toThrow();
18
+ });
19
+
20
+ it("rejects limit above maximum", () => {
21
+ expect(() => paginationSchema.parse({ limit: 100 })).toThrow();
22
+ });
23
+
24
+ it("rejects negative offset", () => {
25
+ expect(() => paginationSchema.parse({ offset: -1 })).toThrow();
26
+ });
27
+ });
28
+
29
+ describe("paginateDefaults", () => {
30
+ it("returns defaults when input is undefined", () => {
31
+ expect(paginateDefaults()).toEqual({ limit: 20, offset: 0 });
32
+ });
33
+
34
+ it("returns defaults when input is empty", () => {
35
+ expect(paginateDefaults({})).toEqual({ limit: 20, offset: 0 });
36
+ });
37
+
38
+ it("uses provided limit", () => {
39
+ expect(paginateDefaults({ limit: 5 })).toEqual({ limit: 5, offset: 0 });
40
+ });
41
+
42
+ it("uses provided offset", () => {
43
+ expect(paginateDefaults({ offset: 10 })).toEqual({ limit: 20, offset: 10 });
44
+ });
45
+ });
46
+
47
+ describe("countSql", () => {
48
+ it("returns a SQL instance", () => {
49
+ const result = countSql("*");
50
+ expect(result).toBeInstanceOf(SQL);
51
+ });
52
+ });
packages/api/src/lib/__tests__/visibility.test.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "bun:test";
2
+ import { SQL } from "drizzle-orm";
3
+ import { buildVisibilityCondition } from "../visibility";
4
+
5
+ const mockTable = {
6
+ isPublic: { name: "isPublic" } as any,
7
+ creatorUserId: { name: "creatorUserId" } as any,
8
+ };
9
+
10
+ describe("buildVisibilityCondition", () => {
11
+ it("returns SQL condition when no userId (public only)", () => {
12
+ const result = buildVisibilityCondition(mockTable);
13
+ expect(result).toBeInstanceOf(SQL);
14
+ });
15
+
16
+ it("returns SQL condition when userId provided (public or own)", () => {
17
+ const result = buildVisibilityCondition(mockTable, "user-123");
18
+ expect(result).toBeInstanceOf(SQL);
19
+ });
20
+ });
packages/api/src/queue.ts CHANGED
@@ -27,7 +27,7 @@ const HEARTBEAT_MS = 10_000;
27
  const MAX_QUESTIONS_PER_SHARD = 6;
28
  const FAST_SHARD_CONCURRENCY = 3;
29
  const QUALITY_SECTION_CONCURRENCY = 2;
30
- const MAX_SHARD_RETRIES = 2;
31
 
32
  interface SectionSplit {
33
  section: string;
@@ -78,7 +78,7 @@ export class GenerationJobCancelledError extends Error {
78
  }
79
  }
80
 
81
- function computeSectionSplit(
82
  selectedSections: string[],
83
  count: number,
84
  ): SectionSplit[] {
@@ -94,7 +94,7 @@ function computeSectionSplit(
94
  }));
95
  }
96
 
97
- function splitIntoShards(sectionSplits: SectionSplit[]): ShardPlan[] {
98
  const shards: ShardPlan[] = [];
99
  for (let sectionIndex = 0; sectionIndex < sectionSplits.length; sectionIndex++) {
100
  const split = sectionSplits[sectionIndex]!;
@@ -136,7 +136,7 @@ function createCancellationPoller(jobId: string) {
136
  };
137
  }
138
 
139
- async function runWithConcurrency<T, R>(
140
  items: T[],
141
  concurrency: number,
142
  worker: (item: T, index: number) => Promise<R>,
@@ -156,7 +156,7 @@ async function runWithConcurrency<T, R>(
156
  return results;
157
  }
158
 
159
- function normalizeQuestions(
160
  section: string,
161
  model: string,
162
  result: GenerationResult,
 
27
  const MAX_QUESTIONS_PER_SHARD = 6;
28
  const FAST_SHARD_CONCURRENCY = 3;
29
  const QUALITY_SECTION_CONCURRENCY = 2;
30
+ export const MAX_SHARD_RETRIES = 2;
31
 
32
  interface SectionSplit {
33
  section: string;
 
78
  }
79
  }
80
 
81
+ export function computeSectionSplit(
82
  selectedSections: string[],
83
  count: number,
84
  ): SectionSplit[] {
 
94
  }));
95
  }
96
 
97
+ export function splitIntoShards(sectionSplits: SectionSplit[]): ShardPlan[] {
98
  const shards: ShardPlan[] = [];
99
  for (let sectionIndex = 0; sectionIndex < sectionSplits.length; sectionIndex++) {
100
  const split = sectionSplits[sectionIndex]!;
 
136
  };
137
  }
138
 
139
+ export async function runWithConcurrency<T, R>(
140
  items: T[],
141
  concurrency: number,
142
  worker: (item: T, index: number) => Promise<R>,
 
156
  return results;
157
  }
158
 
159
+ export function normalizeQuestions(
160
  section: string,
161
  model: string,
162
  result: GenerationResult,
packages/api/src/routers/attempt.ts CHANGED
@@ -1,5 +1,5 @@
1
  import { z } from "zod";
2
- import { eq, and, desc, sql, inArray } from "drizzle-orm";
3
  import { router, protectedProcedure } from "../index";
4
  import { db } from "@labas/db";
5
  import {
@@ -627,6 +627,8 @@ export const attemptRouter = router({
627
  z
628
  .object({
629
  packageId: z.string().uuid().optional(),
 
 
630
  ...paginationSchema.shape,
631
  })
632
  .optional(),
@@ -639,6 +641,12 @@ export const attemptRouter = router({
639
  if (input?.packageId) {
640
  conditions.push(eq(testAttempt.packageId, input.packageId));
641
  }
 
 
 
 
 
 
642
 
643
  const where = and(...conditions);
644
 
@@ -668,6 +676,7 @@ export const attemptRouter = router({
668
  const [countResult] = await db
669
  .select({ count: sql<number>`count(*)` })
670
  .from(testAttempt)
 
671
  .where(where);
672
  const totalCount = Number(countResult?.count ?? 0);
673
 
 
1
  import { z } from "zod";
2
+ import { eq, and, desc, sql, inArray, ilike } from "drizzle-orm";
3
  import { router, protectedProcedure } from "../index";
4
  import { db } from "@labas/db";
5
  import {
 
627
  z
628
  .object({
629
  packageId: z.string().uuid().optional(),
630
+ examTypeId: z.string().optional(),
631
+ search: z.string().optional(),
632
  ...paginationSchema.shape,
633
  })
634
  .optional(),
 
641
  if (input?.packageId) {
642
  conditions.push(eq(testAttempt.packageId, input.packageId));
643
  }
644
+ if (input?.examTypeId) {
645
+ conditions.push(eq(testPackage.examTypeId, input.examTypeId));
646
+ }
647
+ if (input?.search) {
648
+ conditions.push(ilike(testPackage.title, `%${input.search}%`));
649
+ }
650
 
651
  const where = and(...conditions);
652
 
 
676
  const [countResult] = await db
677
  .select({ count: sql<number>`count(*)` })
678
  .from(testAttempt)
679
+ .leftJoin(testPackage, eq(testAttempt.packageId, testPackage.id))
680
  .where(where);
681
  const totalCount = Number(countResult?.count ?? 0);
682
 
packages/api/src/routers/question.ts CHANGED
@@ -82,9 +82,11 @@ export const questionRouter = router({
82
 
83
  if (input?.creatorUserId) {
84
  conditions.push(eq(question.creatorUserId, input.creatorUserId));
85
- } else if (input?.isPublic !== undefined) {
 
 
86
  conditions.push(eq(question.isPublic, input.isPublic));
87
- } else {
88
  const vis = buildVisibilityCondition(question, userId);
89
  if (vis) conditions.push(vis);
90
  }
@@ -296,15 +298,19 @@ export const questionRouter = router({
296
  .from(question)
297
  .where(inArray(question.id, input.ids));
298
 
299
- for (const row of rows) {
300
- assertOwnership(row, ctx.session.user.id, "Question");
 
 
 
301
  }
302
 
 
303
  await db
304
  .update(question)
305
  .set({ isPublic: true })
306
- .where(inArray(question.id, input.ids));
307
 
308
- return { success: true, updated: rows.length };
309
  }),
310
  });
 
82
 
83
  if (input?.creatorUserId) {
84
  conditions.push(eq(question.creatorUserId, input.creatorUserId));
85
+ }
86
+
87
+ if (input?.isPublic !== undefined) {
88
  conditions.push(eq(question.isPublic, input.isPublic));
89
+ } else if (!input?.creatorUserId) {
90
  const vis = buildVisibilityCondition(question, userId);
91
  if (vis) conditions.push(vis);
92
  }
 
298
  .from(question)
299
  .where(inArray(question.id, input.ids));
300
 
301
+ const ownRows = rows.filter((r) => r.creatorUserId === ctx.session.user.id);
302
+ const skipped = rows.length - ownRows.length;
303
+
304
+ if (ownRows.length === 0) {
305
+ throwNotFound("Question");
306
  }
307
 
308
+ const ownIds = ownRows.map((r) => r.id);
309
  await db
310
  .update(question)
311
  .set({ isPublic: true })
312
+ .where(inArray(question.id, ownIds));
313
 
314
+ return { success: true, updated: ownRows.length, skipped };
315
  }),
316
  });