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- AGENTS.md +39 -1
- apps/web/package.json +1 -0
- apps/web/src/components/bank/CalloutCard.tsx +48 -0
- apps/web/src/components/bank/FilterBar.tsx +46 -0
- apps/web/src/components/bank/QuestionCard.tsx +9 -3
- apps/web/src/components/bank/SoalBrowser.tsx +32 -0
- apps/web/src/lib/__tests__/avatar-url.test.ts +95 -0
- apps/web/src/lib/__tests__/difficulty-mapping.test.ts +42 -0
- apps/web/src/lib/__tests__/exam-constants.test.ts +68 -0
- apps/web/src/lib/__tests__/format.test.ts +21 -0
- apps/web/src/lib/__tests__/generate-constants.test.ts +91 -0
- apps/web/src/lib/__tests__/time.test.ts +34 -0
- apps/web/src/routes/bank.tsx +28 -5
- apps/web/src/routes/history.tsx +68 -1
- bun.lock +3 -0
- package.json +1 -0
- packages/ai/package.json +3 -0
- packages/ai/src/__tests__/client.test.ts +212 -0
- packages/ai/src/__tests__/errors.test.ts +24 -0
- packages/ai/src/__tests__/prompts.test.ts +122 -0
- packages/ai/src/__tests__/repair.test.ts +218 -0
- packages/ai/src/__tests__/schema-to-prompt.test.ts +67 -0
- packages/ai/src/__tests__/schemas.test.ts +265 -0
- packages/ai/src/client.ts +1 -1
- packages/ai/src/schema-to-prompt.ts +3 -10
- packages/api/package.json +4 -1
- packages/api/src/__tests__/attempt.integration.test.ts +207 -0
- packages/api/src/__tests__/package.integration.test.ts +229 -0
- packages/api/src/__tests__/question.integration.test.ts +96 -0
- packages/api/src/__tests__/queue.test.ts +228 -0
- packages/api/src/__tests__/test-db.ts +65 -0
- packages/api/src/__tests__/test-setup.ts +84 -0
- packages/api/src/lib/__tests__/encryption.test.ts +54 -0
- packages/api/src/lib/__tests__/errors.test.ts +71 -0
- packages/api/src/lib/__tests__/ownership.test.ts +63 -0
- packages/api/src/lib/__tests__/pagination.test.ts +52 -0
- packages/api/src/lib/__tests__/visibility.test.ts +20 -0
- packages/api/src/queue.ts +5 -5
- packages/api/src/routers/attempt.ts +10 -1
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
:
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 116 |
q.isPublic
|
| 117 |
? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
|
| 118 |
-
: "bg-[var(--
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 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 =
|
| 10 |
-
|
| 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 |
-
}
|
|
|
|
|
|
|
| 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 |
-
|
| 300 |
-
|
|
|
|
|
|
|
|
|
|
| 301 |
}
|
| 302 |
|
|
|
|
| 303 |
await db
|
| 304 |
.update(question)
|
| 305 |
.set({ isPublic: true })
|
| 306 |
-
.where(inArray(question.id,
|
| 307 |
|
| 308 |
-
return { success: true, updated:
|
| 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 |
});
|