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:
parent
8605b1b517
commit
45a47e0ffd
31 changed files with 1897 additions and 106 deletions
28
apps/api/src/db/connection.ts
Normal file
28
apps/api/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
|
||||
import * as schema from './schema/index.ts';
|
||||
|
||||
export type CardsDb = PostgresJsDatabase<typeof schema>;
|
||||
|
||||
let _db: CardsDb | null = null;
|
||||
let _client: postgres.Sql<{}> | null = null;
|
||||
|
||||
export function getDb(): CardsDb {
|
||||
if (_db) return _db;
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
throw new Error('DATABASE_URL not set — Cards-API kann nicht ohne Postgres laufen');
|
||||
}
|
||||
_client = postgres(url, { max: 10 });
|
||||
_db = drizzle(_client, { schema });
|
||||
return _db;
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
if (_client) {
|
||||
await _client.end();
|
||||
_client = null;
|
||||
_db = null;
|
||||
}
|
||||
}
|
||||
7
apps/api/src/db/schema/_schema.ts
Normal file
7
apps/api/src/db/schema/_schema.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Single Source of Truth für die pgSchema-Deklaration.
|
||||
// Wird von allen Tabellen-Files importiert, vermeidet Zirkular-Imports
|
||||
// (siehe Lesson aus mana-share/-events während F-0).
|
||||
|
||||
import { pgSchema } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const cardsSchema = pgSchema('cards');
|
||||
65
apps/api/src/db/schema/cards.ts
Normal file
65
apps/api/src/db/schema/cards.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { index, jsonb, primaryKey, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { cardsSchema } from './_schema.ts';
|
||||
import { decks } from './decks.ts';
|
||||
|
||||
/**
|
||||
* Karten. `fields` ist ein generischer JSONB-Slot, der je nach `type`
|
||||
* unterschiedliche Felder enthält:
|
||||
*
|
||||
* - basic / basic-reverse: { front, back }
|
||||
* - cloze: { text, extra? }
|
||||
* - type-in: { question, expected }
|
||||
* - image-occlusion: { image_ref, mask_regions: [...] }
|
||||
*
|
||||
* MVP unterstützt nur `basic` und `basic-reverse`.
|
||||
*
|
||||
* `tags` ist ein eigener Tag-Mapping-Layer (siehe `tags.ts` + `cardTags`),
|
||||
* NICHT in dieser Tabelle gespeichert.
|
||||
*/
|
||||
export const cards = cardsSchema.table(
|
||||
'cards',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
deckId: text('deck_id')
|
||||
.notNull()
|
||||
.references(() => decks.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id').notNull(),
|
||||
type: text('type').notNull(),
|
||||
fields: jsonb('fields').notNull(),
|
||||
mediaRefs: jsonb('media_refs').notNull().$type<string[]>().default([]),
|
||||
contentHash: text('content_hash'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
deckIdx: index('cards_deck_idx').on(t.deckId),
|
||||
userIdx: index('cards_user_idx').on(t.userId),
|
||||
})
|
||||
);
|
||||
|
||||
export type CardRow = typeof cards.$inferSelect;
|
||||
export type CardInsert = typeof cards.$inferInsert;
|
||||
|
||||
/**
|
||||
* Card↔Tag-Mapping. PK ist (card_id, tag_id) — kein eigenes id-Feld nötig.
|
||||
*/
|
||||
export const cardTags = cardsSchema.table(
|
||||
'card_tags',
|
||||
{
|
||||
cardId: text('card_id')
|
||||
.notNull()
|
||||
.references(() => cards.id, { onDelete: 'cascade' }),
|
||||
tagId: text('tag_id').notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
pk: primaryKey({ columns: [t.cardId, t.tagId] }),
|
||||
})
|
||||
);
|
||||
|
||||
export type CardTagRow = typeof cardTags.$inferSelect;
|
||||
export type CardTagInsert = typeof cardTags.$inferInsert;
|
||||
37
apps/api/src/db/schema/decks.ts
Normal file
37
apps/api/src/db/schema/decks.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { sql } from 'drizzle-orm';
|
||||
import { index, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { cardsSchema } from './_schema.ts';
|
||||
|
||||
/**
|
||||
* Decks — Sammlungen von Karten. Eine Karte gehört zu genau einem Deck.
|
||||
* `fsrs_settings` ist ein JSONB-Slot für per-Deck-Overrides der globalen
|
||||
* FSRS-Defaults (siehe @cards/domain `FsrsSettings`).
|
||||
*/
|
||||
export const decks = cardsSchema.table(
|
||||
'decks',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
color: text('color'),
|
||||
visibility: text('visibility', { enum: ['private', 'space', 'public'] })
|
||||
.notNull()
|
||||
.default('private'),
|
||||
fsrsSettings: jsonb('fsrs_settings').notNull().default(sql`'{}'::jsonb`),
|
||||
contentHash: text('content_hash'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index('decks_user_idx').on(t.userId),
|
||||
})
|
||||
);
|
||||
|
||||
export type DeckRow = typeof decks.$inferSelect;
|
||||
export type DeckInsert = typeof decks.$inferInsert;
|
||||
34
apps/api/src/db/schema/imports.ts
Normal file
34
apps/api/src/db/schema/imports.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { index, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { cardsSchema } from './_schema.ts';
|
||||
|
||||
/**
|
||||
* Import-Jobs für Bulk-Vorgänge (Anki-.apkg, CSV-Upload, etc.).
|
||||
* Der Job-Status durchläuft `queued` → `processing` → `done | failed`.
|
||||
* `meta` ist ein freier JSONB-Slot für Source-spezifische Infos
|
||||
* (Datei-Name, Mapping-Tabellen, Fortschritt).
|
||||
*/
|
||||
export const importJobs = cardsSchema.table(
|
||||
'import_jobs',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull(),
|
||||
source: text('source', { enum: ['anki', 'csv', 'json'] }).notNull(),
|
||||
state: text('state', { enum: ['queued', 'processing', 'done', 'failed'] })
|
||||
.notNull()
|
||||
.default('queued'),
|
||||
meta: jsonb('meta'),
|
||||
error: text('error'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
finishedAt: timestamp('finished_at', { withTimezone: true, mode: 'date' }),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index('imports_user_idx').on(t.userId),
|
||||
stateIdx: index('imports_state_idx').on(t.state),
|
||||
})
|
||||
);
|
||||
|
||||
export type ImportJobRow = typeof importJobs.$inferSelect;
|
||||
export type ImportJobInsert = typeof importJobs.$inferInsert;
|
||||
|
|
@ -1,13 +1,29 @@
|
|||
// Drizzle-Schemas für die `cards`-Datenbank.
|
||||
// Public Re-exports für Drizzle-Schemas.
|
||||
//
|
||||
// Phase-3-Aufgabe (siehe CARDS_GREENFIELD.md): hier landen
|
||||
// `decks`, `cards`, `reviews`, `study_sessions`, `tags`, `media_refs`,
|
||||
// `import_jobs` als pgSchema('cards').table(...) Definitionen.
|
||||
//
|
||||
// Schema-Skizze in mana/docs/playbooks/CARDS_GREENFIELD.md §"Drizzle-Schema-Skizze".
|
||||
// Card-Type-Granularität (subIndex pro Karte) aus
|
||||
// docs/LESSONS_FROM_MANA_MONOREPO.md mitnehmen.
|
||||
// Konvention: Tabellen-Files importieren `cardsSchema` aus `_schema.ts`
|
||||
// (nie aus `index.ts`), damit es keine Zirkular-Imports gibt.
|
||||
|
||||
import { pgSchema } from 'drizzle-orm/pg-core';
|
||||
export { cardsSchema } from './_schema.ts';
|
||||
|
||||
export const cardsSchema = pgSchema('cards');
|
||||
export { decks } from './decks.ts';
|
||||
export type { DeckRow, DeckInsert } from './decks.ts';
|
||||
|
||||
export { cards, cardTags } from './cards.ts';
|
||||
export type { CardRow, CardInsert, CardTagRow, CardTagInsert } from './cards.ts';
|
||||
|
||||
export { reviews, studySessions } from './reviews.ts';
|
||||
export type {
|
||||
ReviewRow,
|
||||
ReviewInsert,
|
||||
StudySessionRow,
|
||||
StudySessionInsert,
|
||||
} from './reviews.ts';
|
||||
|
||||
export { tags } from './tags.ts';
|
||||
export type { TagRow, TagInsert } from './tags.ts';
|
||||
|
||||
export { mediaRefs } from './media.ts';
|
||||
export type { MediaRefRow, MediaRefInsert } from './media.ts';
|
||||
|
||||
export { importJobs } from './imports.ts';
|
||||
export type { ImportJobRow, ImportJobInsert } from './imports.ts';
|
||||
|
|
|
|||
32
apps/api/src/db/schema/media.ts
Normal file
32
apps/api/src/db/schema/media.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { index, integer, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { cardsSchema } from './_schema.ts';
|
||||
import { cards } from './cards.ts';
|
||||
|
||||
/**
|
||||
* Media-Verweise auf Object-IDs in mana-media. Die eigentlichen Files
|
||||
* (Bilder, Audio, Video) liegen in MinIO via mana-media; diese Tabelle
|
||||
* hält nur den Verweis + Sortier-Order pro Karte.
|
||||
*/
|
||||
export const mediaRefs = cardsSchema.table(
|
||||
'media_refs',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
cardId: text('card_id')
|
||||
.notNull()
|
||||
.references(() => cards.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id').notNull(),
|
||||
manaMediaObjectId: text('mana_media_object_id').notNull(),
|
||||
kind: text('kind', { enum: ['image', 'audio', 'video'] }).notNull(),
|
||||
ord: integer('ord').notNull().default(0),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
cardIdx: index('media_card_idx').on(t.cardId),
|
||||
})
|
||||
);
|
||||
|
||||
export type MediaRefRow = typeof mediaRefs.$inferSelect;
|
||||
export type MediaRefInsert = typeof mediaRefs.$inferInsert;
|
||||
76
apps/api/src/db/schema/reviews.ts
Normal file
76
apps/api/src/db/schema/reviews.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { index, integer, primaryKey, real, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { cardsSchema } from './_schema.ts';
|
||||
import { cards } from './cards.ts';
|
||||
import { decks } from './decks.ts';
|
||||
|
||||
/**
|
||||
* FSRS-Review-State pro `(card, sub_index)`.
|
||||
*
|
||||
* `sub_index` granular:
|
||||
* - basic: 1 Review (sub_index = 0)
|
||||
* - basic-reverse: 2 Reviews (0 = front→back, 1 = back→front)
|
||||
* - cloze: 1 Review pro Cluster-Index ({{c1::…}} → sub_index = 1)
|
||||
*
|
||||
* **Bewusst PLAINTEXT** (siehe `docs/LESSONS_FROM_MANA_MONOREPO.md` §3):
|
||||
* Der Scheduler quert täglich `due <= now` — Encryption müsste das
|
||||
* jedes Mal entschlüsseln. mana-monorepo hat das gleiche Pattern:
|
||||
* cardReviews ist plaintext-allowlisted.
|
||||
*/
|
||||
export const reviews = cardsSchema.table(
|
||||
'reviews',
|
||||
{
|
||||
cardId: text('card_id')
|
||||
.notNull()
|
||||
.references(() => cards.id, { onDelete: 'cascade' }),
|
||||
subIndex: integer('sub_index').notNull().default(0),
|
||||
userId: text('user_id').notNull(),
|
||||
due: timestamp('due', { withTimezone: true, mode: 'date' }).notNull(),
|
||||
stability: real('stability').notNull(),
|
||||
difficulty: real('difficulty').notNull(),
|
||||
elapsedDays: real('elapsed_days').notNull().default(0),
|
||||
scheduledDays: real('scheduled_days').notNull().default(0),
|
||||
reps: integer('reps').notNull().default(0),
|
||||
lapses: integer('lapses').notNull().default(0),
|
||||
state: text('state', { enum: ['new', 'learning', 'review', 'relearning'] })
|
||||
.notNull()
|
||||
.default('new'),
|
||||
lastReview: timestamp('last_review', { withTimezone: true, mode: 'date' }),
|
||||
},
|
||||
(t) => ({
|
||||
pk: primaryKey({ columns: [t.cardId, t.subIndex] }),
|
||||
// Hot Path: Scheduler quert täglich `due <= now` für einen User.
|
||||
userDueIdx: index('reviews_user_due_idx').on(t.userId, t.due),
|
||||
})
|
||||
);
|
||||
|
||||
export type ReviewRow = typeof reviews.$inferSelect;
|
||||
export type ReviewInsert = typeof reviews.$inferInsert;
|
||||
|
||||
/**
|
||||
* Study-Sessions als Statistik-Layer. Eine Session läuft pro
|
||||
* `(user, deck)`-Studieren-Lauf, wird beim Start angelegt und beim
|
||||
* Ende mit Total-Counts geupdatet.
|
||||
*/
|
||||
export const studySessions = cardsSchema.table(
|
||||
'study_sessions',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull(),
|
||||
deckId: text('deck_id')
|
||||
.notNull()
|
||||
.references(() => decks.id, { onDelete: 'cascade' }),
|
||||
startedAt: timestamp('started_at', { withTimezone: true, mode: 'date' })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
finishedAt: timestamp('finished_at', { withTimezone: true, mode: 'date' }),
|
||||
cardsReviewed: integer('cards_reviewed').notNull().default(0),
|
||||
cardsCorrect: integer('cards_correct').notNull().default(0),
|
||||
},
|
||||
(t) => ({
|
||||
userStartedIdx: index('sessions_user_started_idx').on(t.userId, t.startedAt),
|
||||
})
|
||||
);
|
||||
|
||||
export type StudySessionRow = typeof studySessions.$inferSelect;
|
||||
export type StudySessionInsert = typeof studySessions.$inferInsert;
|
||||
30
apps/api/src/db/schema/tags.ts
Normal file
30
apps/api/src/db/schema/tags.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { index, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { cardsSchema } from './_schema.ts';
|
||||
import { decks } from './decks.ts';
|
||||
|
||||
/**
|
||||
* Tags sind deck-skopiert (laut mana-monorepo-Pattern). Ein Tag-Name
|
||||
* kann pro Deck nur einmal vorkommen.
|
||||
*/
|
||||
export const tags = cardsSchema.table(
|
||||
'tags',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
deckId: text('deck_id')
|
||||
.notNull()
|
||||
.references(() => decks.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
deckIdx: index('tags_deck_idx').on(t.deckId),
|
||||
uniqueByDeckName: uniqueIndex('tags_deck_name_uniq').on(t.deckId, t.name),
|
||||
})
|
||||
);
|
||||
|
||||
export type TagRow = typeof tags.$inferSelect;
|
||||
export type TagInsert = typeof tags.$inferInsert;
|
||||
|
|
@ -2,11 +2,17 @@ import { Hono } from 'hono';
|
|||
|
||||
import { manifestRoute } from './routes/manifest.ts';
|
||||
import { healthRoute } from './routes/health.ts';
|
||||
import { decksRouter } from './routes/decks.ts';
|
||||
import { cardsRouter } from './routes/cards.ts';
|
||||
import { reviewsRouter } from './routes/reviews.ts';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.route('/', healthRoute);
|
||||
app.route('/.well-known/mana-app.json', manifestRoute);
|
||||
app.route('/api/v1/decks', decksRouter());
|
||||
app.route('/api/v1/cards', cardsRouter());
|
||||
app.route('/api/v1/reviews', reviewsRouter());
|
||||
|
||||
app.get('/', (c) =>
|
||||
c.json({
|
||||
|
|
|
|||
46
apps/api/src/lib/ulid.ts
Normal file
46
apps/api/src/lib/ulid.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* ULID-Generator (Crockford-Base-32).
|
||||
* Bewusst lokal statt ulid-Lib, damit keine zusätzliche Abhängigkeit.
|
||||
*
|
||||
* 26 Zeichen: 10 Time + 16 Random.
|
||||
* Lexikografisch sortierbar, monoton steigend.
|
||||
*/
|
||||
|
||||
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||||
|
||||
function randomBytes(n: number): Uint8Array {
|
||||
const buf = new Uint8Array(n);
|
||||
crypto.getRandomValues(buf);
|
||||
return buf;
|
||||
}
|
||||
|
||||
function encodeTime(now: number): string {
|
||||
let s = '';
|
||||
let n = now;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const r = n % 32;
|
||||
s = CROCKFORD[r] + s;
|
||||
n = Math.floor(n / 32);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function encodeRandom(): string {
|
||||
const bytes = randomBytes(10);
|
||||
let bits = 0;
|
||||
let acc = 0;
|
||||
let s = '';
|
||||
for (const b of bytes) {
|
||||
acc = (acc << 8) | b;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
bits -= 5;
|
||||
s += CROCKFORD[(acc >> bits) & 0x1f];
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function ulid(now: number = Date.now()): string {
|
||||
return encodeTime(now) + encodeRandom();
|
||||
}
|
||||
27
apps/api/src/middleware/auth.ts
Normal file
27
apps/api/src/middleware/auth.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { Context, MiddlewareHandler } from 'hono';
|
||||
|
||||
/**
|
||||
* Auth-Middleware-Stub für Phase 3.
|
||||
*
|
||||
* Heute (Dev): liest `X-User-Id`-Header.
|
||||
* Phase 2 (echt): validiert User-JWT gegen mana-auth JWKS und extrahiert
|
||||
* `sub`-Claim als userId.
|
||||
*
|
||||
* Implementations-Notiz: Phase 2 schwenkt auf `@mana/shared-hono`'s
|
||||
* `authMiddleware()` um, das den JWKS-Cache verwaltet.
|
||||
*/
|
||||
export type AuthVars = { userId: string };
|
||||
|
||||
export const authMiddleware: MiddlewareHandler<{ Variables: AuthVars }> = async (c, next) => {
|
||||
const userId = c.req.header('X-User-Id');
|
||||
if (!userId) {
|
||||
return c.json({ error: 'unauthenticated', detail: 'X-User-Id header missing (dev stub)' }, 401);
|
||||
}
|
||||
c.set('userId', userId);
|
||||
await next();
|
||||
};
|
||||
|
||||
/** Helper zum Auslesen des userId aus dem Context (typed). */
|
||||
export function getUserId(c: Context<{ Variables: AuthVars }>): string {
|
||||
return c.get('userId');
|
||||
}
|
||||
163
apps/api/src/routes/cards.ts
Normal file
163
apps/api/src/routes/cards.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { and, eq } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { CardCreateSchema, CardUpdateSchema, newReview, subIndexCount } from '@cards/domain';
|
||||
|
||||
import { getDb, type CardsDb } from '../db/connection.ts';
|
||||
import { cards, decks, reviews } from '../db/schema/index.ts';
|
||||
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
||||
import { ulid } from '../lib/ulid.ts';
|
||||
|
||||
export type CardsDeps = { db?: CardsDb };
|
||||
|
||||
export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }> {
|
||||
const r = new Hono<{ Variables: AuthVars }>();
|
||||
const dbOf = () => deps.db ?? getDb();
|
||||
|
||||
r.use('*', authMiddleware);
|
||||
|
||||
/**
|
||||
* Karte erstellen + automatisch initiale Reviews anlegen.
|
||||
*
|
||||
* Pro Card-Type werden N `(card_id, sub_index)`-Reviews angelegt
|
||||
* (basic = 1, basic-reverse = 2). Alles in einer Transaktion.
|
||||
*/
|
||||
r.post('/', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const parsed = CardCreateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return c.json(
|
||||
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
|
||||
422
|
||||
);
|
||||
}
|
||||
const userId = c.get('userId');
|
||||
|
||||
const [deck] = await dbOf()
|
||||
.select({ id: decks.id, userId: decks.userId })
|
||||
.from(decks)
|
||||
.where(eq(decks.id, parsed.data.deck_id))
|
||||
.limit(1);
|
||||
if (!deck) return c.json({ error: 'deck_not_found' }, 404);
|
||||
if (deck.userId !== userId) return c.json({ error: 'deck_not_owned' }, 403);
|
||||
|
||||
const cardId = ulid();
|
||||
const now = new Date();
|
||||
const subIndices = Array.from({ length: subIndexCount(parsed.data.type) }, (_, i) => i);
|
||||
|
||||
const [cardRow] = await dbOf().transaction(async (tx) => {
|
||||
const [card] = await tx
|
||||
.insert(cards)
|
||||
.values({
|
||||
id: cardId,
|
||||
deckId: parsed.data.deck_id,
|
||||
userId,
|
||||
type: parsed.data.type,
|
||||
fields: parsed.data.fields,
|
||||
mediaRefs: parsed.data.media_refs ?? [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const initialReviews = subIndices.map((subIndex) => {
|
||||
const r = newReview({ userId, cardId, subIndex, now });
|
||||
return {
|
||||
cardId: r.card_id,
|
||||
subIndex: r.sub_index,
|
||||
userId: r.user_id,
|
||||
due: new Date(r.due),
|
||||
stability: r.stability,
|
||||
difficulty: r.difficulty,
|
||||
elapsedDays: r.elapsed_days,
|
||||
scheduledDays: r.scheduled_days,
|
||||
reps: r.reps,
|
||||
lapses: r.lapses,
|
||||
state: r.state,
|
||||
lastReview: r.last_review ? new Date(r.last_review) : null,
|
||||
};
|
||||
});
|
||||
if (initialReviews.length > 0) {
|
||||
await tx.insert(reviews).values(initialReviews);
|
||||
}
|
||||
|
||||
return [card];
|
||||
});
|
||||
|
||||
return c.json(toCardDto(cardRow), 201);
|
||||
});
|
||||
|
||||
r.get('/', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const deckId = c.req.query('deck_id');
|
||||
const conditions = deckId
|
||||
? and(eq(cards.userId, userId), eq(cards.deckId, deckId))
|
||||
: eq(cards.userId, userId);
|
||||
const rows = await dbOf().select().from(cards).where(conditions);
|
||||
return c.json({ cards: rows.map(toCardDto), total: rows.length });
|
||||
});
|
||||
|
||||
r.get('/:id', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const id = c.req.param('id');
|
||||
const [row] = await dbOf()
|
||||
.select()
|
||||
.from(cards)
|
||||
.where(and(eq(cards.id, id), eq(cards.userId, userId)))
|
||||
.limit(1);
|
||||
if (!row) return c.json({ error: 'not_found' }, 404);
|
||||
return c.json(toCardDto(row));
|
||||
});
|
||||
|
||||
r.patch('/:id', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const parsed = CardUpdateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return c.json(
|
||||
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
|
||||
422
|
||||
);
|
||||
}
|
||||
const [row] = await dbOf()
|
||||
.update(cards)
|
||||
.set({
|
||||
...(parsed.data.fields !== undefined && { fields: parsed.data.fields }),
|
||||
...(parsed.data.media_refs !== undefined && { mediaRefs: parsed.data.media_refs }),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(cards.id, id), eq(cards.userId, userId)))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: 'not_found' }, 404);
|
||||
return c.json(toCardDto(row));
|
||||
});
|
||||
|
||||
r.delete('/:id', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const id = c.req.param('id');
|
||||
const result = await dbOf()
|
||||
.delete(cards)
|
||||
.where(and(eq(cards.id, id), eq(cards.userId, userId)))
|
||||
.returning({ id: cards.id });
|
||||
if (result.length === 0) return c.json({ error: 'not_found' }, 404);
|
||||
// reviews kaskadiert per onDelete: 'cascade' in der Schema-Definition.
|
||||
return c.json({ deleted: id });
|
||||
});
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
function toCardDto(row: typeof cards.$inferSelect) {
|
||||
return {
|
||||
id: row.id,
|
||||
deck_id: row.deckId,
|
||||
user_id: row.userId,
|
||||
type: row.type,
|
||||
fields: row.fields,
|
||||
media_refs: row.mediaRefs ?? [],
|
||||
content_hash: row.contentHash,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
updated_at: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
123
apps/api/src/routes/decks.ts
Normal file
123
apps/api/src/routes/decks.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { and, eq } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { DeckCreateSchema, DeckUpdateSchema } from '@cards/domain';
|
||||
|
||||
import { getDb, type CardsDb } from '../db/connection.ts';
|
||||
import { decks } from '../db/schema/index.ts';
|
||||
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
||||
import { ulid } from '../lib/ulid.ts';
|
||||
|
||||
/** Optional injectable DB für Tests. */
|
||||
export type DecksDeps = { db?: CardsDb };
|
||||
|
||||
export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }> {
|
||||
const r = new Hono<{ Variables: AuthVars }>();
|
||||
const dbOf = () => deps.db ?? getDb();
|
||||
|
||||
r.use('*', authMiddleware);
|
||||
|
||||
r.post('/', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const parsed = DeckCreateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return c.json(
|
||||
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
|
||||
422
|
||||
);
|
||||
}
|
||||
const userId = c.get('userId');
|
||||
const id = ulid();
|
||||
const now = new Date();
|
||||
const [row] = await dbOf()
|
||||
.insert(decks)
|
||||
.values({
|
||||
id,
|
||||
userId,
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
color: parsed.data.color,
|
||||
visibility: parsed.data.visibility ?? 'private',
|
||||
fsrsSettings: parsed.data.fsrs_settings ?? {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
return c.json(toDeckDto(row), 201);
|
||||
});
|
||||
|
||||
r.get('/', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const rows = await dbOf().select().from(decks).where(eq(decks.userId, userId));
|
||||
return c.json({ decks: rows.map(toDeckDto), total: rows.length });
|
||||
});
|
||||
|
||||
r.get('/:id', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const id = c.req.param('id');
|
||||
const [row] = await dbOf()
|
||||
.select()
|
||||
.from(decks)
|
||||
.where(and(eq(decks.id, id), eq(decks.userId, userId)))
|
||||
.limit(1);
|
||||
if (!row) return c.json({ error: 'not_found' }, 404);
|
||||
return c.json(toDeckDto(row));
|
||||
});
|
||||
|
||||
r.patch('/:id', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const parsed = DeckUpdateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return c.json(
|
||||
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
|
||||
422
|
||||
);
|
||||
}
|
||||
const [row] = await dbOf()
|
||||
.update(decks)
|
||||
.set({
|
||||
...(parsed.data.name !== undefined && { name: parsed.data.name }),
|
||||
...(parsed.data.description !== undefined && { description: parsed.data.description }),
|
||||
...(parsed.data.color !== undefined && { color: parsed.data.color }),
|
||||
...(parsed.data.visibility !== undefined && { visibility: parsed.data.visibility }),
|
||||
...(parsed.data.fsrs_settings !== undefined && {
|
||||
fsrsSettings: parsed.data.fsrs_settings,
|
||||
}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(decks.id, id), eq(decks.userId, userId)))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: 'not_found' }, 404);
|
||||
return c.json(toDeckDto(row));
|
||||
});
|
||||
|
||||
r.delete('/:id', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const id = c.req.param('id');
|
||||
const result = await dbOf()
|
||||
.delete(decks)
|
||||
.where(and(eq(decks.id, id), eq(decks.userId, userId)))
|
||||
.returning({ id: decks.id });
|
||||
if (result.length === 0) return c.json({ error: 'not_found' }, 404);
|
||||
return c.json({ deleted: id });
|
||||
});
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
function toDeckDto(row: typeof decks.$inferSelect) {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.userId,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
color: row.color,
|
||||
visibility: row.visibility,
|
||||
fsrs_settings: row.fsrsSettings,
|
||||
content_hash: row.contentHash,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
updated_at: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
160
apps/api/src/routes/reviews.ts
Normal file
160
apps/api/src/routes/reviews.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { and, asc, eq, lte } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import {
|
||||
GradeReviewInputSchema,
|
||||
gradeReview,
|
||||
type Review as DomainReview,
|
||||
} from '@cards/domain';
|
||||
|
||||
import { getDb, type CardsDb } from '../db/connection.ts';
|
||||
import { cards, decks, reviews } from '../db/schema/index.ts';
|
||||
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
||||
|
||||
export type ReviewsDeps = { db?: CardsDb };
|
||||
|
||||
export function reviewsRouter(deps: ReviewsDeps = {}): Hono<{ Variables: AuthVars }> {
|
||||
const r = new Hono<{ Variables: AuthVars }>();
|
||||
const dbOf = () => deps.db ?? getDb();
|
||||
|
||||
r.use('*', authMiddleware);
|
||||
|
||||
/**
|
||||
* Hot Path: alle Reviews holen, deren due <= now ist.
|
||||
* Optional auf ein Deck eingeschränkt. Default-Limit: 100.
|
||||
*
|
||||
* Index: `reviews_user_due_idx` deckt diese Query ab.
|
||||
*/
|
||||
r.get('/due', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const deckId = c.req.query('deck_id');
|
||||
const limit = Math.min(Number(c.req.query('limit') ?? 100), 500);
|
||||
const now = new Date();
|
||||
|
||||
const conditions = [eq(reviews.userId, userId), lte(reviews.due, now)];
|
||||
|
||||
if (deckId) {
|
||||
// Wenn deck_id angegeben, joinen wir auf cards.deck_id.
|
||||
const rows = await dbOf()
|
||||
.select({
|
||||
review: reviews,
|
||||
card: { id: cards.id, deckId: cards.deckId, type: cards.type, fields: cards.fields },
|
||||
})
|
||||
.from(reviews)
|
||||
.innerJoin(cards, eq(cards.id, reviews.cardId))
|
||||
.where(and(...conditions, eq(cards.deckId, deckId)))
|
||||
.orderBy(asc(reviews.due))
|
||||
.limit(limit);
|
||||
return c.json({
|
||||
reviews: rows.map((r) => ({ ...toReviewDto(r.review), card: r.card })),
|
||||
total: rows.length,
|
||||
});
|
||||
}
|
||||
|
||||
const rows = await dbOf()
|
||||
.select()
|
||||
.from(reviews)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(reviews.due))
|
||||
.limit(limit);
|
||||
return c.json({ reviews: rows.map(toReviewDto), total: rows.length });
|
||||
});
|
||||
|
||||
/**
|
||||
* User bewertet eine Karte. FSRS rechnet nächste due-time aus,
|
||||
* wir schreiben das Update zurück.
|
||||
*/
|
||||
r.post('/:cardId/:subIndex/grade', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const cardId = c.req.param('cardId');
|
||||
const subIndex = Number(c.req.param('subIndex'));
|
||||
if (!Number.isInteger(subIndex) || subIndex < 0) {
|
||||
return c.json({ error: 'invalid_sub_index' }, 422);
|
||||
}
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const parsed = GradeReviewInputSchema.safeParse({
|
||||
...((body as object) ?? {}),
|
||||
card_id: cardId,
|
||||
sub_index: subIndex,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return c.json(
|
||||
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
|
||||
422
|
||||
);
|
||||
}
|
||||
|
||||
// Aktuellen Review-State holen + Deck-FSRS-Settings für den Scheduler.
|
||||
const [hit] = await dbOf()
|
||||
.select({
|
||||
review: reviews,
|
||||
deck: { fsrsSettings: decks.fsrsSettings },
|
||||
})
|
||||
.from(reviews)
|
||||
.innerJoin(cards, eq(cards.id, reviews.cardId))
|
||||
.innerJoin(decks, eq(decks.id, cards.deckId))
|
||||
.where(
|
||||
and(
|
||||
eq(reviews.cardId, cardId),
|
||||
eq(reviews.subIndex, subIndex),
|
||||
eq(reviews.userId, userId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!hit) return c.json({ error: 'not_found' }, 404);
|
||||
|
||||
const reviewedAt = parsed.data.reviewed_at ? new Date(parsed.data.reviewed_at) : new Date();
|
||||
const currentDomain: DomainReview = toReviewDto(hit.review);
|
||||
const next = gradeReview(
|
||||
currentDomain,
|
||||
parsed.data.rating,
|
||||
reviewedAt,
|
||||
(hit.deck.fsrsSettings as object) ?? {}
|
||||
);
|
||||
|
||||
const [updated] = await dbOf()
|
||||
.update(reviews)
|
||||
.set({
|
||||
due: new Date(next.due),
|
||||
stability: next.stability,
|
||||
difficulty: next.difficulty,
|
||||
elapsedDays: next.elapsed_days,
|
||||
scheduledDays: next.scheduled_days,
|
||||
reps: next.reps,
|
||||
lapses: next.lapses,
|
||||
state: next.state,
|
||||
lastReview: next.last_review ? new Date(next.last_review) : null,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(reviews.cardId, cardId),
|
||||
eq(reviews.subIndex, subIndex),
|
||||
eq(reviews.userId, userId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
return c.json(toReviewDto(updated));
|
||||
});
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
function toReviewDto(row: typeof reviews.$inferSelect): DomainReview {
|
||||
return {
|
||||
card_id: row.cardId,
|
||||
sub_index: row.subIndex,
|
||||
user_id: row.userId,
|
||||
due: row.due.toISOString(),
|
||||
stability: row.stability,
|
||||
difficulty: row.difficulty,
|
||||
elapsed_days: row.elapsedDays,
|
||||
scheduled_days: row.scheduledDays,
|
||||
reps: row.reps,
|
||||
lapses: row.lapses,
|
||||
state: row.state,
|
||||
last_review: row.lastReview ? row.lastReview.toISOString() : null,
|
||||
};
|
||||
}
|
||||
98
apps/api/tests/cards.test.ts
Normal file
98
apps/api/tests/cards.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { cardsRouter } from '../src/routes/cards.ts';
|
||||
import type { CardsDb } from '../src/db/connection.ts';
|
||||
|
||||
/**
|
||||
* Routen-Tests ohne echte DB. Drizzle-Aufrufe werden durch eine
|
||||
* minimale Stub-DB ersetzt, die nur die Validations-Pfade abdeckt.
|
||||
*/
|
||||
|
||||
function buildApp() {
|
||||
const app = new Hono();
|
||||
const stub = {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({ limit: () => [] }),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
app.route('/api/v1/cards', cardsRouter({ db: stub as unknown as CardsDb }));
|
||||
return { app };
|
||||
}
|
||||
|
||||
describe('cardsRouter — auth-gate', () => {
|
||||
it('GET ohne X-User-Id ist 401', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await app.request('/api/v1/cards');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cardsRouter — Input-Validation', () => {
|
||||
it('POST mit leerem Body ist 422', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await app.request('/api/v1/cards', {
|
||||
method: 'POST',
|
||||
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it('POST mit basic-Card ohne back-Feld ist 422', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await app.request('/api/v1/cards', {
|
||||
method: 'POST',
|
||||
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
deck_id: 'd-1',
|
||||
type: 'basic',
|
||||
fields: { front: 'Q' },
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it('POST mit unknown CardType ist 422', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await app.request('/api/v1/cards', {
|
||||
method: 'POST',
|
||||
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
deck_id: 'd-1',
|
||||
type: 'cloze',
|
||||
fields: { text: 'x' },
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it('PATCH mit extra prop ist 422', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await app.request('/api/v1/cards/c-1', {
|
||||
method: 'PATCH',
|
||||
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fields: { front: 'X' }, leak: 'bad' }),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it('POST mit gültigem basic-Card erreicht Deck-Lookup (404 bei stub)', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await app.request('/api/v1/cards', {
|
||||
method: 'POST',
|
||||
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
deck_id: 'd-1',
|
||||
type: 'basic',
|
||||
fields: { front: 'Q', back: 'A' },
|
||||
}),
|
||||
});
|
||||
// Stub-DB gibt empty array → Deck-Not-Found-Pfad
|
||||
expect(res.status).toBe(404);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe('deck_not_found');
|
||||
});
|
||||
});
|
||||
164
apps/api/tests/decks.test.ts
Normal file
164
apps/api/tests/decks.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { decksRouter } from '../src/routes/decks.ts';
|
||||
import type { CardsDb } from '../src/db/connection.ts';
|
||||
|
||||
/**
|
||||
* Routen-Tests ohne echte DB. Wir mocken die paar Drizzle-Methoden, die
|
||||
* der Decks-Router nutzt, mit einem winzigen In-Memory-Store.
|
||||
*
|
||||
* Echte Integrations-Tests (gegen postgres/pg-mem) folgen in einer späteren
|
||||
* Phase, wenn die Test-Infra steht.
|
||||
*/
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string | null;
|
||||
visibility: 'private' | 'space' | 'public';
|
||||
fsrsSettings: unknown;
|
||||
contentHash: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
function makeFakeDb() {
|
||||
const store = new Map<string, Row>();
|
||||
|
||||
const fakeDb = {
|
||||
insert: (_table: unknown) => ({
|
||||
values: (vals: Partial<Row> & { id: string; userId: string }) => ({
|
||||
returning: async () => {
|
||||
const row: Row = {
|
||||
id: vals.id,
|
||||
userId: vals.userId,
|
||||
name: vals.name ?? '',
|
||||
description: vals.description ?? null,
|
||||
color: vals.color ?? null,
|
||||
visibility: vals.visibility ?? 'private',
|
||||
fsrsSettings: vals.fsrsSettings ?? {},
|
||||
contentHash: vals.contentHash ?? null,
|
||||
createdAt: vals.createdAt ?? new Date(),
|
||||
updatedAt: vals.updatedAt ?? new Date(),
|
||||
};
|
||||
store.set(row.id, row);
|
||||
return [row];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
select: () => ({
|
||||
from: (_table: unknown) => ({
|
||||
where: (filter: { userId?: string; id?: string }) => {
|
||||
const items = Array.from(store.values()).filter((r) => {
|
||||
if (filter.userId && r.userId !== filter.userId) return false;
|
||||
if (filter.id && r.id !== filter.id) return false;
|
||||
return true;
|
||||
});
|
||||
return Object.assign(items as Row[] | Promise<Row[]>, {
|
||||
limit: (_n: number) => items.slice(0, _n),
|
||||
});
|
||||
},
|
||||
}),
|
||||
}),
|
||||
update: (_table: unknown) => ({
|
||||
set: (patch: Partial<Row>) => ({
|
||||
where: (filter: { userId: string; id: string }) => ({
|
||||
returning: async () => {
|
||||
const existing = store.get(filter.id);
|
||||
if (!existing || existing.userId !== filter.userId) return [];
|
||||
const updated = { ...existing, ...patch, updatedAt: new Date() };
|
||||
store.set(updated.id, updated);
|
||||
return [updated];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
delete: (_table: unknown) => ({
|
||||
where: (filter: { userId: string; id: string }) => ({
|
||||
returning: async () => {
|
||||
const existing = store.get(filter.id);
|
||||
if (!existing || existing.userId !== filter.userId) return [];
|
||||
store.delete(filter.id);
|
||||
return [{ id: filter.id }];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
// Drizzle's eq/and dont actually pass a function-based filter; the fake-db
|
||||
// shim above doesn't match real Drizzle wire-shape. So we override the
|
||||
// interpretation: the test patches eq/and via a simpler comparator.
|
||||
// For now this fake-DB is sufficient ONLY if the routes' .where()-args
|
||||
// arrive as plain { userId, id } objects. They don't — they arrive as
|
||||
// Drizzle-SQL builders. So tests below are scoped to validation/auth paths,
|
||||
// not full CRUD.
|
||||
|
||||
return { fakeDb, store };
|
||||
}
|
||||
|
||||
function buildApp() {
|
||||
const { fakeDb } = makeFakeDb();
|
||||
// Cast — the fakeDb is intentionally minimal and not a full CardsDb.
|
||||
const app = new Hono();
|
||||
app.route('/api/v1/decks', decksRouter({ db: fakeDb as unknown as CardsDb }));
|
||||
return { app };
|
||||
}
|
||||
|
||||
describe('decksRouter — auth-gate', () => {
|
||||
it('rejects requests without X-User-Id with 401', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await app.request('/api/v1/decks');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('lets through with X-User-Id (no DB call)', async () => {
|
||||
const { app } = buildApp();
|
||||
// POST with invalid input should reach the validation step, not 401.
|
||||
const res = await app.request('/api/v1/decks', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-User-Id': 'u-1',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: '{}',
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decksRouter — input validation', () => {
|
||||
it('POST with empty body is 422', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await app.request('/api/v1/decks', {
|
||||
method: 'POST',
|
||||
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe('invalid_input');
|
||||
});
|
||||
|
||||
it('POST with bad color is 422', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await app.request('/api/v1/decks', {
|
||||
method: 'POST',
|
||||
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'D', color: 'red' }),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it('PATCH with extra prop is 422', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await app.request('/api/v1/decks/d-1', {
|
||||
method: 'PATCH',
|
||||
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'X', leak: 'bad' }),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
89
apps/api/tests/reviews.test.ts
Normal file
89
apps/api/tests/reviews.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { reviewsRouter } from '../src/routes/reviews.ts';
|
||||
import type { CardsDb } from '../src/db/connection.ts';
|
||||
|
||||
function buildApp() {
|
||||
const stub = {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
innerJoin: () => ({
|
||||
innerJoin: () => ({
|
||||
where: () => ({ limit: () => [] }),
|
||||
}),
|
||||
where: () => ({
|
||||
orderBy: () => ({ limit: () => [] }),
|
||||
}),
|
||||
}),
|
||||
where: () => ({
|
||||
orderBy: () => ({ limit: () => [] }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
const app = new Hono();
|
||||
app.route('/api/v1/reviews', reviewsRouter({ db: stub as unknown as CardsDb }));
|
||||
return { app };
|
||||
}
|
||||
|
||||
describe('reviewsRouter — auth-gate', () => {
|
||||
it('GET /due ohne X-User-Id ist 401', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await app.request('/api/v1/reviews/due');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('POST /grade ohne X-User-Id ist 401', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await app.request('/api/v1/reviews/c-1/0/grade', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ rating: 'good' }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reviewsRouter — Input-Validation', () => {
|
||||
it('POST mit invalid sub_index ist 422', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await app.request('/api/v1/reviews/c-1/-1/grade', {
|
||||
method: 'POST',
|
||||
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rating: 'good' }),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it('POST ohne rating ist 422', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await app.request('/api/v1/reviews/c-1/0/grade', {
|
||||
method: 'POST',
|
||||
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it('POST mit unknown rating ist 422', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await app.request('/api/v1/reviews/c-1/0/grade', {
|
||||
method: 'POST',
|
||||
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rating: 'maybe' }),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it('POST mit gültigem rating erreicht Lookup (404 bei stub)', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await app.request('/api/v1/reviews/c-1/0/grade', {
|
||||
method: 'POST',
|
||||
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rating: 'good' }),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe('not_found');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue