Phase 3: Domain-Modell + Decks/Cards/Reviews-CRUD

Domain (@cards/domain):
- zod-Schemas SSOT für Deck, Card, Review, StudySession, FsrsSettings,
  Tools (cards.create + cards.search Input/Output)
- CardType-Discriminated-Union: MVP basic+basic-reverse, Future-Set
  (cloze, type-in, image-occlusion, audio, multiple-choice) für
  Schema-stable-Migration vorbereitet
- validateFieldsForType() Pure-Function pro CardType
- FSRS-Adapter über ts-fsrs v5.3.2: newReview, gradeReview,
  subIndexCount, toFsrsCard/fromFsrsCard ISO↔Date-Roundtrip
- Encryption-Hinweis: reviews bleiben PLAINTEXT (Scheduler quert
  täglich `due <= now`, siehe Lessons §3)

Drizzle-Schemas (apps/api/src/db/schema, alles in pgSchema('cards')):
- decks, cards, card_tags, reviews (PK card_id+sub_index), study_sessions,
  tags (deck-skopiert), media_refs (verweist auf mana-media), import_jobs
- _schema.ts-Pattern um Zirkular-Imports zu vermeiden (Lesson aus
  mana-share/-events während F-0)
- Hot-Path-Index reviews_user_due_idx für Scheduler-Queries

Routes (apps/api/src/routes):
- POST/GET/PATCH/DELETE /api/v1/decks (Deck-CRUD)
- POST/GET/PATCH/DELETE /api/v1/cards (Card-CRUD mit Auto-Reviews-Init:
  beim Card-Insert werden N Reviews via subIndexCount(type) angelegt,
  in einer Transaktion)
- GET /api/v1/reviews/due (Hot-Path, optional deck_id-Filter, Limit 500)
- POST /api/v1/reviews/:cardId/:subIndex/grade (FSRS-State-Transition,
  per-Deck FSRS-Settings)

Auth: Stub-Middleware liest X-User-Id-Header (Phase 2 ersetzt durch
@mana/shared-hono authMiddleware mit JWKS-Cache).

Tests (vitest, Hono app.request()):
- @cards/domain: fsrs.test.ts (newReview, gradeReview Roundtrip,
  Rating-Mapping), schemas.test.ts (zod-strict-Variants, Field-Type-
  Validation, hex-Color)
- apps/api: decks.test.ts + cards.test.ts + reviews.test.ts —
  Auth-Gate + Input-Validation. Volle DB-Integrationstests folgen mit
  pg-mem oder testcontainers in späterer Phase.

Cleanup: types.ts entfernt, zod-Schemas sind SSOT (z.infer für Types).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till 2026-05-08 14:21:54 +02:00
parent 8605b1b517
commit 45a47e0ffd
31 changed files with 1897 additions and 106 deletions

View file

@ -8,9 +8,7 @@
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./types": "./src/types.ts",
"./fsrs": "./src/fsrs.ts",
"./cloze": "./src/cloze.ts",
"./schemas": "./src/schemas/index.ts"
},
"scripts": {

View file

@ -0,0 +1,153 @@
// FSRS-Adapter über ts-fsrs v5.3.2.
//
// Pattern aus mana-monorepo (siehe docs/LESSONS_FROM_MANA_MONOREPO.md §3):
// - Unsere Reviews speichern ISO-Strings + camelCase-Felder.
// - ts-fsrs arbeitet mit Date-Objekten + snake_case.
// - Diese Datei ist die Übersetzungs-Schicht.
// - Reviews bleiben PLAINTEXT (Scheduler quert täglich `due <= now`).
import {
createEmptyCard,
default_w,
FSRS,
generatorParameters,
Rating as FsrsRating,
State as FsrsState,
type Card as FsrsCard,
type FSRSParameters,
} from 'ts-fsrs';
import type { Rating, Review, ReviewState } from './schemas/review.ts';
import type { FsrsSettings } from './schemas/fsrs-settings.ts';
/** Public Rating ↔ ts-fsrs Rating mapping. */
const RATING_TO_FSRS: Record<Rating, FsrsRating> = {
again: FsrsRating.Again,
hard: FsrsRating.Hard,
good: FsrsRating.Good,
easy: FsrsRating.Easy,
};
/** ts-fsrs State (numeric) ↔ unser ReviewState (string). */
const STATE_FROM_FSRS: Record<FsrsState, ReviewState> = {
[FsrsState.New]: 'new',
[FsrsState.Learning]: 'learning',
[FsrsState.Review]: 'review',
[FsrsState.Relearning]: 'relearning',
};
const STATE_TO_FSRS: Record<ReviewState, FsrsState> = {
new: FsrsState.New,
learning: FsrsState.Learning,
review: FsrsState.Review,
relearning: FsrsState.Relearning,
};
/** Baut einen FSRS-Scheduler aus per-Deck-Settings + globalen Defaults. */
export function buildScheduler(settings: FsrsSettings = {}): FSRS {
const params: FSRSParameters = generatorParameters({
request_retention: settings.request_retention,
maximum_interval: settings.maximum_interval,
w: settings.w ?? default_w,
enable_fuzz: settings.enable_fuzz ?? true,
});
return new FSRS(params);
}
/** Initialer Review-State für eine neue Karte (sub_index). */
export function newReview(args: {
userId: string;
cardId: string;
subIndex?: number;
now?: Date;
}): Review {
const now = args.now ?? new Date();
const fc = createEmptyCard(now);
return {
card_id: args.cardId,
sub_index: args.subIndex ?? 0,
user_id: args.userId,
due: fc.due.toISOString(),
stability: fc.stability,
difficulty: fc.difficulty,
elapsed_days: fc.elapsed_days,
scheduled_days: fc.scheduled_days,
reps: fc.reps,
lapses: fc.lapses,
state: STATE_FROM_FSRS[fc.state],
last_review: fc.last_review ? fc.last_review.toISOString() : null,
};
}
/** Wendet ein User-Rating an und gibt den nächsten Review-State zurück. */
export function gradeReview(
current: Review,
rating: Rating,
now?: Date,
settings?: FsrsSettings
): Review {
const reviewedAt = now ?? new Date();
const scheduler = buildScheduler(settings);
const fc = toFsrsCard(current);
const log = scheduler.repeat(fc, reviewedAt);
const next = log[RATING_TO_FSRS[rating]].card;
return fromFsrsCard(current, next);
}
/** Konvertiert unseren Review-Datensatz in eine ts-fsrs Card. */
export function toFsrsCard(r: Review): FsrsCard {
return {
due: new Date(r.due),
stability: r.stability,
difficulty: r.difficulty,
elapsed_days: r.elapsed_days,
scheduled_days: r.scheduled_days,
reps: r.reps,
lapses: r.lapses,
state: STATE_TO_FSRS[r.state],
last_review: r.last_review ? new Date(r.last_review) : undefined,
};
}
/** Übernimmt FSRS-Result-Card-Felder in unser Review-Objekt. */
export function fromFsrsCard(prev: Review, fc: FsrsCard): Review {
return {
...prev,
due: fc.due.toISOString(),
stability: fc.stability,
difficulty: fc.difficulty,
elapsed_days: fc.elapsed_days,
scheduled_days: fc.scheduled_days,
reps: fc.reps,
lapses: fc.lapses,
state: STATE_FROM_FSRS[fc.state],
last_review: fc.last_review ? fc.last_review.toISOString() : null,
};
}
/**
* Wie viele Reviews pro Card-Type? Wird beim Card-Insert genutzt,
* um die `(card_id, sub_index)`-Reihen zu initialisieren.
*
* Cloze ist Sonderfall: `subIndex` hängt von der Anzahl der Cluster
* im `fields.text` ab; `subIndexCountForCloze(text)` muss separat
* gerufen werden, sobald wir Cloze unterstützen.
*/
export function subIndexCount(type: string): number {
switch (type) {
case 'basic':
return 1;
case 'basic-reverse':
return 2;
case 'type-in':
return 1;
case 'image-occlusion':
return 1; // pro Mask-Region in Phase 8+ angepasst
case 'audio':
return 1;
case 'multiple-choice':
return 1;
default:
return 1;
}
}

View file

@ -2,7 +2,11 @@
//
// Pure-TS, keine DB/Framework-Abhängigkeiten. Wird vom apps/api
// (Drizzle-Schemas) und apps/web (UI) gleichermaßen konsumiert.
//
// Single Source of Truth für Domain-Typen sind die zod-Schemas in
// `./schemas/`. Alle Types werden via `z.infer<typeof XSchema>`
// aus den Schemas abgeleitet — kein duplizierter Type-Layer.
export * from './types.ts';
// export * from './fsrs.ts'; // Phase 3: FSRS-Adapter (ts-fsrs)
export * from './schemas/index.ts';
export * from './fsrs.ts';
// export * from './cloze.ts'; // Phase 8 oder später: Cloze-Parser

View file

@ -0,0 +1,94 @@
import { z } from 'zod';
/**
* MVP-CardType-Set. Erweiterung in CARDS_GREENFIELD.md Phase 8+ vorgesehen
* (cloze, type-in, image-occlusion, audio, multiple-choice).
*/
export const CardTypeSchema = z.enum(['basic', 'basic-reverse']);
export type CardType = z.infer<typeof CardTypeSchema>;
/** Future-Set für Schema-Migration-Vorbereitung. */
export const CardTypeFutureSchema = z.enum([
'basic',
'basic-reverse',
'cloze',
'type-in',
'image-occlusion',
'audio',
'multiple-choice',
]);
/**
* Generischer Field-Slot. Konkrete Field-Sets pro Type werden runtime
* via `validateFieldsForType()` geprüft (siehe unten).
*/
export const CardFieldsSchema = z.record(z.string(), z.string());
export type CardFields = z.infer<typeof CardFieldsSchema>;
/**
* Field-Set-Validierung pro CardType. Keine z.discriminatedUnion, weil
* der Type-Slot generisch bleibt neue CardTypes können ohne Schema-
* Bruch hinzugefügt werden.
*/
export function validateFieldsForType(
type: CardType | z.infer<typeof CardTypeFutureSchema>,
fields: CardFields
): { ok: true } | { ok: false; missing: string[] } {
const required: Record<string, string[]> = {
basic: ['front', 'back'],
'basic-reverse': ['front', 'back'],
cloze: ['text'],
'type-in': ['question', 'expected'],
'image-occlusion': ['image_ref', 'mask_regions'],
audio: ['audio_ref'],
'multiple-choice': ['question', 'options', 'correct_index'],
};
const need = required[type] ?? [];
const missing = need.filter((k) => !(k in fields));
return missing.length === 0 ? { ok: true } : { ok: false, missing };
}
export const CardSchema = z
.object({
id: z.string().min(1),
deck_id: z.string().min(1),
user_id: z.string().min(1),
type: CardTypeSchema,
fields: CardFieldsSchema,
media_refs: z.array(z.string()).default([]),
content_hash: z.string().optional().nullable(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
})
.strict();
export type Card = z.infer<typeof CardSchema>;
export const CardCreateSchema = z
.object({
deck_id: z.string().min(1),
type: CardTypeSchema,
fields: CardFieldsSchema,
tags: z.array(z.string()).optional(),
media_refs: z.array(z.string()).optional(),
})
.strict()
.superRefine((val, ctx) => {
const check = validateFieldsForType(val.type, val.fields);
if (!check.ok) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['fields'],
message: `missing fields for type=${val.type}: ${check.missing.join(', ')}`,
});
}
});
export type CardCreate = z.infer<typeof CardCreateSchema>;
export const CardUpdateSchema = z
.object({
fields: CardFieldsSchema.optional(),
tags: z.array(z.string()).optional(),
media_refs: z.array(z.string()).optional(),
})
.strict();
export type CardUpdate = z.infer<typeof CardUpdateSchema>;

View file

@ -0,0 +1,42 @@
import { z } from 'zod';
import { FsrsSettingsSchema } from './fsrs-settings.ts';
const VisibilitySchema = z.enum(['private', 'space', 'public']);
export const DeckSchema = z
.object({
id: z.string().min(1),
user_id: z.string().min(1),
name: z.string().min(1).max(200),
description: z.string().max(2000).optional().nullable(),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.optional()
.nullable(),
visibility: VisibilitySchema.default('private'),
fsrs_settings: FsrsSettingsSchema.default({}),
content_hash: z.string().optional().nullable(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
})
.strict();
export type Deck = z.infer<typeof DeckSchema>;
export const DeckCreateSchema = z
.object({
name: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.optional(),
visibility: VisibilitySchema.optional(),
fsrs_settings: FsrsSettingsSchema.optional(),
})
.strict();
export type DeckCreate = z.infer<typeof DeckCreateSchema>;
export const DeckUpdateSchema = DeckCreateSchema.partial();
export type DeckUpdate = z.infer<typeof DeckUpdateSchema>;

View file

@ -0,0 +1,15 @@
import { z } from 'zod';
/**
* FSRS-Settings (siehe ts-fsrs `FSRSParameters`).
* Alle Felder optional; Defaults werden im Adapter gesetzt.
*/
export const FsrsSettingsSchema = z
.object({
request_retention: z.number().min(0.5).max(0.99).optional(),
maximum_interval: z.number().min(1).max(36500).optional(),
w: z.array(z.number()).length(19).optional(),
enable_fuzz: z.boolean().optional(),
})
.strict();
export type FsrsSettings = z.infer<typeof FsrsSettingsSchema>;

View file

@ -1,7 +1,17 @@
// Phase-3-Aufgabe: zod-Schemas für API-Inputs/Outputs.
// Re-exports für zod-Schemas und JSON-Schema-Generierung.
//
// Konvention: ein zod-Schema pro Domain-Type (DeckSchema, CardSchema,
// ReviewSchema). API-Routen rufen `Schema.parse()` für Input-Validation,
// und `zod-to-json-schema` generiert die JSON-Schemas für mana-mcp/-share.
// Verwendung:
// - apps/api: Input-Validation via `Schema.parse(body)`
// - apps/api: zod-to-json-schema für mana-mcp Tool-Registration
// - apps/web: Type-Inference (`type X = z.infer<typeof XSchema>`)
//
// Konvention: ein Schema-File pro Domain-Bereich. Public-API-Re-Exports
// hier; das Mapping `validateFieldsForType` ist eine Pure-Function aus
// `card.ts`.
export {};
export * from './fsrs-settings.ts';
export * from './deck.ts';
export * from './card.ts';
export * from './review.ts';
export * from './study.ts';
export * from './tools.ts';

View file

@ -0,0 +1,36 @@
import { z } from 'zod';
export const ReviewStateSchema = z.enum(['new', 'learning', 'review', 'relearning']);
export type ReviewState = z.infer<typeof ReviewStateSchema>;
/** ts-fsrs Rating-Map: 1=Again, 2=Hard, 3=Good, 4=Easy. */
export const RatingSchema = z.enum(['again', 'hard', 'good', 'easy']);
export type Rating = z.infer<typeof RatingSchema>;
export const ReviewSchema = z
.object({
card_id: z.string().min(1),
sub_index: z.number().int().nonnegative(),
user_id: z.string().min(1),
due: z.string().datetime(),
stability: z.number().nonnegative(),
difficulty: z.number().min(0).max(10),
elapsed_days: z.number().nonnegative().default(0),
scheduled_days: z.number().nonnegative().default(0),
reps: z.number().int().nonnegative().default(0),
lapses: z.number().int().nonnegative().default(0),
state: ReviewStateSchema.default('new'),
last_review: z.string().datetime().nullable().optional(),
})
.strict();
export type Review = z.infer<typeof ReviewSchema>;
export const GradeReviewInputSchema = z
.object({
card_id: z.string().min(1),
sub_index: z.number().int().nonnegative().default(0),
rating: RatingSchema,
reviewed_at: z.string().datetime().optional(),
})
.strict();
export type GradeReviewInput = z.infer<typeof GradeReviewInputSchema>;

View file

@ -0,0 +1,30 @@
import { z } from 'zod';
export const StudySessionSchema = z
.object({
id: z.string().min(1),
user_id: z.string().min(1),
deck_id: z.string().min(1),
started_at: z.string().datetime(),
finished_at: z.string().datetime().nullable(),
cards_reviewed: z.number().int().nonnegative().default(0),
cards_correct: z.number().int().nonnegative().default(0),
})
.strict();
export type StudySession = z.infer<typeof StudySessionSchema>;
export const StartStudySessionInputSchema = z
.object({
deck_id: z.string().min(1),
})
.strict();
export type StartStudySessionInput = z.infer<typeof StartStudySessionInputSchema>;
export const FinishStudySessionInputSchema = z
.object({
session_id: z.string().min(1),
cards_reviewed: z.number().int().nonnegative(),
cards_correct: z.number().int().nonnegative(),
})
.strict();
export type FinishStudySessionInput = z.infer<typeof FinishStudySessionInputSchema>;

View file

@ -0,0 +1,45 @@
import { z } from 'zod';
import { CardSchema, CardCreateSchema } from './card.ts';
/**
* Schemas für die im app-manifest.json deklarierten AI-Tools.
* Werden via zod-to-json-schema in JSON-Schema konvertiert und an
* mana-mcp ausgeliefert (Phase 7).
*/
export const CardsCreateInputSchema = CardCreateSchema;
export type CardsCreateInput = z.infer<typeof CardsCreateInputSchema>;
export const CardsCreateOutputSchema = CardSchema;
export type CardsCreateOutput = z.infer<typeof CardsCreateOutputSchema>;
export const CardsSearchInputSchema = z
.object({
query: z.string().min(1),
max_results: z.number().int().min(1).max(100).optional(),
})
.strict();
export type CardsSearchInput = z.infer<typeof CardsSearchInputSchema>;
export const CardsSearchHitSchema = z
.object({
id: z.string(),
type: z.literal('card'),
title: z.string(),
snippet: z.string().optional(),
link: z.string().url(),
score: z.number().min(0).max(1),
})
.strict();
export type CardsSearchHit = z.infer<typeof CardsSearchHitSchema>;
export const CardsSearchOutputSchema = z
.object({
query: z.string(),
results: z.array(CardsSearchHitSchema),
total: z.number().int().nonnegative(),
took_ms: z.number().nonnegative(),
})
.strict();
export type CardsSearchOutput = z.infer<typeof CardsSearchOutputSchema>;

View file

@ -1,87 +0,0 @@
// Domain-Typen für Cards.
//
// Modellierung folgt den Lessons aus mana-monorepo
// (siehe docs/LESSONS_FROM_MANA_MONOREPO.md):
//
// - CardType ist eine discriminated union.
// - Card hat `fields: Record<string, string>` als generischen Slot.
// - Pro Karte gibt es N Reviews mit `subIndex` (basic = 1, basic-reverse = 2,
// cloze = 1 pro Cluster).
// - cardReviews bleiben PLAINTEXT, weil der Scheduler täglich auf `due`
// filtert.
/** Phase-1-MVP-Set; Cloze + Image-Occlusion in Phase 8+. */
export type CardType = 'basic' | 'basic-reverse';
/** Voll geplantes Set (für Schemas vorbereitet, MVP nicht alle implementiert). */
export type CardTypeFuture =
| 'basic'
| 'basic-reverse'
| 'cloze'
| 'type-in'
| 'image-occlusion'
| 'audio'
| 'multiple-choice';
export type CardFields = Record<string, string>;
export type Deck = {
id: string;
user_id: string;
name: string;
description?: string;
color?: string;
visibility: 'private' | 'space' | 'public';
fsrs_settings: FsrsSettings;
created_at: string;
updated_at: string;
};
export type Card = {
id: string;
deck_id: string;
user_id: string;
type: CardType;
fields: CardFields;
tags: string[];
media_refs: string[];
created_at: string;
updated_at: string;
};
/**
* Pro `(card_id, sub_index)` ein Review-Eintrag. Der Scheduler quert auf
* `due <= now` täglich das Feld bleibt deshalb plaintext und ist indiziert.
*/
export type Review = {
card_id: string;
sub_index: number;
user_id: string;
due: string;
stability: number;
difficulty: number;
elapsed_days: number;
scheduled_days: number;
reps: number;
lapses: number;
state: 'new' | 'learning' | 'review' | 'relearning';
last_review: string | null;
};
export type StudySession = {
id: string;
user_id: string;
deck_id: string;
started_at: string;
finished_at: string | null;
cards_reviewed: number;
cards_correct: number;
};
/** Default-Konstanten aus ts-fsrs; per-Deck-Overrides möglich. */
export type FsrsSettings = {
requestRetention?: number;
maximumInterval?: number;
w?: number[];
enableFuzz?: boolean;
};

View file

@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import {
buildScheduler,
fromFsrsCard,
gradeReview,
newReview,
subIndexCount,
toFsrsCard,
} from '../src/fsrs.ts';
describe('newReview', () => {
it('initializes with state=new and reps=0', () => {
const r = newReview({
userId: 'u-1',
cardId: 'c-1',
now: new Date('2026-05-08T10:00:00Z'),
});
expect(r.card_id).toBe('c-1');
expect(r.sub_index).toBe(0);
expect(r.user_id).toBe('u-1');
expect(r.state).toBe('new');
expect(r.reps).toBe(0);
expect(r.lapses).toBe(0);
expect(r.due).toBe('2026-05-08T10:00:00.000Z');
});
it('honours provided sub_index', () => {
const r = newReview({ userId: 'u-1', cardId: 'c-1', subIndex: 2 });
expect(r.sub_index).toBe(2);
});
});
describe('gradeReview', () => {
const fixedNow = new Date('2026-05-08T10:00:00Z');
const reviewedAt = new Date('2026-05-08T10:01:00Z');
const baseReview = newReview({
userId: 'u-1',
cardId: 'c-1',
now: fixedNow,
});
it('Again from new keeps reps=0 increments lapses', () => {
// Disable fuzz for deterministic test outputs
const next = gradeReview(baseReview, 'again', reviewedAt, { enable_fuzz: false });
expect(next.state).not.toBe('new');
expect(next.due).not.toBe(baseReview.due);
expect(next.reps).toBeGreaterThanOrEqual(1);
});
it('Easy from new transitions to a future-dated review', () => {
const next = gradeReview(baseReview, 'easy', reviewedAt, { enable_fuzz: false });
expect(new Date(next.due).getTime()).toBeGreaterThan(reviewedAt.getTime());
});
it('preserves card_id, sub_index, user_id', () => {
const next = gradeReview(baseReview, 'good', reviewedAt, { enable_fuzz: false });
expect(next.card_id).toBe(baseReview.card_id);
expect(next.sub_index).toBe(baseReview.sub_index);
expect(next.user_id).toBe(baseReview.user_id);
});
});
describe('toFsrsCard / fromFsrsCard roundtrip', () => {
it('roundtrips a new review without loss', () => {
const r = newReview({ userId: 'u-1', cardId: 'c-1' });
const fc = toFsrsCard(r);
const back = fromFsrsCard(r, fc);
expect(back.due).toBe(r.due);
expect(back.stability).toBe(r.stability);
expect(back.state).toBe(r.state);
});
});
describe('subIndexCount', () => {
it('basic = 1, basic-reverse = 2', () => {
expect(subIndexCount('basic')).toBe(1);
expect(subIndexCount('basic-reverse')).toBe(2);
});
it('unknown type defaults to 1', () => {
expect(subIndexCount('unknown-future-type')).toBe(1);
});
});
describe('buildScheduler', () => {
it('builds with defaults', () => {
const s = buildScheduler();
expect(s).toBeDefined();
});
it('honours per-deck overrides', () => {
const s = buildScheduler({ request_retention: 0.85, enable_fuzz: false });
expect(s).toBeDefined();
});
});

View file

@ -0,0 +1,154 @@
import { describe, it, expect } from 'vitest';
import {
CardCreateSchema,
CardSchema,
CardTypeSchema,
DeckCreateSchema,
DeckSchema,
GradeReviewInputSchema,
validateFieldsForType,
} from '../src/schemas/index.ts';
describe('CardTypeSchema', () => {
it('accepts MVP types', () => {
expect(() => CardTypeSchema.parse('basic')).not.toThrow();
expect(() => CardTypeSchema.parse('basic-reverse')).not.toThrow();
});
it('rejects future types in MVP schema', () => {
expect(() => CardTypeSchema.parse('cloze')).toThrow();
});
});
describe('validateFieldsForType', () => {
it('basic requires front + back', () => {
expect(validateFieldsForType('basic', { front: 'q', back: 'a' })).toEqual({ ok: true });
expect(validateFieldsForType('basic', { front: 'q' })).toEqual({
ok: false,
missing: ['back'],
});
});
it('cloze requires text', () => {
expect(validateFieldsForType('cloze', { text: 'x' })).toEqual({ ok: true });
expect(validateFieldsForType('cloze', {})).toEqual({ ok: false, missing: ['text'] });
});
});
describe('CardCreateSchema', () => {
it('accepts a basic card', () => {
const r = CardCreateSchema.safeParse({
deck_id: 'd-1',
type: 'basic',
fields: { front: 'Q', back: 'A' },
});
expect(r.success).toBe(true);
});
it('rejects basic card without back', () => {
const r = CardCreateSchema.safeParse({
deck_id: 'd-1',
type: 'basic',
fields: { front: 'Q' },
});
expect(r.success).toBe(false);
});
it('rejects unknown type via CardTypeSchema', () => {
const r = CardCreateSchema.safeParse({
deck_id: 'd-1',
type: 'cloze',
fields: { text: 'x' },
});
expect(r.success).toBe(false);
});
it('rejects extra fields (strict)', () => {
const r = CardCreateSchema.safeParse({
deck_id: 'd-1',
type: 'basic',
fields: { front: 'Q', back: 'A' },
malicious: 'inject',
});
expect(r.success).toBe(false);
});
});
describe('DeckCreateSchema', () => {
it('minimal valid deck', () => {
const r = DeckCreateSchema.safeParse({ name: 'My Deck' });
expect(r.success).toBe(true);
});
it('rejects empty name', () => {
const r = DeckCreateSchema.safeParse({ name: '' });
expect(r.success).toBe(false);
});
it('rejects invalid color', () => {
const r = DeckCreateSchema.safeParse({ name: 'X', color: 'red' });
expect(r.success).toBe(false);
});
it('accepts hex color', () => {
const r = DeckCreateSchema.safeParse({ name: 'X', color: '#ff8800' });
expect(r.success).toBe(true);
});
});
describe('GradeReviewInputSchema', () => {
it('accepts a grade input', () => {
const r = GradeReviewInputSchema.safeParse({
card_id: 'c-1',
sub_index: 0,
rating: 'good',
});
expect(r.success).toBe(true);
});
it('rejects unknown rating', () => {
const r = GradeReviewInputSchema.safeParse({
card_id: 'c-1',
sub_index: 0,
rating: 'perfect',
});
expect(r.success).toBe(false);
});
it('defaults sub_index to 0', () => {
const r = GradeReviewInputSchema.parse({ card_id: 'c-1', rating: 'good' });
expect(r.sub_index).toBe(0);
});
});
describe('strict variants reject extras', () => {
it('DeckSchema rejects extra props', () => {
const r = DeckSchema.safeParse({
id: 'd-1',
user_id: 'u-1',
name: 'D',
visibility: 'private',
fsrs_settings: {},
created_at: '2026-05-08T10:00:00.000Z',
updated_at: '2026-05-08T10:00:00.000Z',
leaks: 'no',
});
expect(r.success).toBe(false);
});
it('CardSchema rejects extra props', () => {
const r = CardSchema.safeParse({
id: 'c-1',
deck_id: 'd-1',
user_id: 'u-1',
type: 'basic',
fields: { front: 'Q', back: 'A' },
media_refs: [],
created_at: '2026-05-08T10:00:00.000Z',
updated_at: '2026-05-08T10:00:00.000Z',
leaked: 'yes',
});
expect(r.success).toBe(false);
});
});