Neuer Domain-Helper cardContentHash({ type, fields }) — SHA-256 über
canonisierten JSON ({type, sorted-fields}), pure Web-Crypto. Field-
Reihenfolge ist invariant; Whitespace + Cloze-Markup zählen mit
(zwei Karten mit identischem Text aber unterschiedlichem
{{c1::…}}-Markup sind verschiedene Karten).
cards-API POST schreibt content_hash automatisch in den schon
existierenden Schema-Slot. Neuer Endpoint GET /api/v1/cards/hashes
liefert die kompakte Hash-Liste des Users (ohne Card-Body) — eine
Anfrage pro Anki-Import statt pro Karte.
apps/web/src/lib/anki/import.ts holt die Hashes vor dem Loop und
prüft pro Karte clientseitig. Duplikate werden gezählt
(cardsSkippedDuplicate) und übersprungen, der Counter erscheint
in der AnkiImport-Done-View. Same-File-Drift (Anki-interne
Doppel-Notes) wird auch erkannt — nach erfolgreichem Insert
landet der Hash sofort im Set.
Fallback: wenn /hashes fehlschlägt (älterer Server), bleibt das
Dedupe-Set leer und Karten werden eingefügt wie zuvor — kein
Hard-Bruch.
Pre-Phase-9j-Karten haben null content_hash (Hashes-Endpoint
filtert sie weg) — sie können also irrtümlich erneut eingespielt
werden, falls noch im Anki-File. Pragmatisch akzeptiert: ein
Backfill-Script wäre Phase-10-Polish, sobald Live-User da sind.
5 neue Domain-Tests, 1 neuer API-Auth-Gate-Test (105 grün ges.:
51 + 49 + 5). svelte-check 380 files 0 errors. E2E gegen lokale
Postgres bestätigt: neue Karte hat content_hash (64-char-hex),
/hashes listet sie.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
150 lines
4.4 KiB
TypeScript
150 lines
4.4 KiB
TypeScript
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);
|
|
});
|
|
|
|
it('GET /hashes ohne X-User-Id ist 401', async () => {
|
|
const { app } = buildApp();
|
|
const res = await app.request('/api/v1/cards/hashes');
|
|
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: 'image-occlusion',
|
|
fields: { image_ref: 'x', mask_regions: 'y' },
|
|
}),
|
|
});
|
|
expect(res.status).toBe(422);
|
|
});
|
|
|
|
it('POST mit cloze-Card ohne text-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: 'cloze',
|
|
fields: {},
|
|
}),
|
|
});
|
|
expect(res.status).toBe(422);
|
|
});
|
|
|
|
it('POST mit cloze-Card aber Text ohne Cluster 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: 'plain text without any {{cN::…}} markup' },
|
|
}),
|
|
});
|
|
expect(res.status).toBe(422);
|
|
const body = (await res.json()) as { error: string; issues: string[] };
|
|
expect(body.issues[0]).toMatch(/cloze\.text/);
|
|
});
|
|
|
|
it('POST mit gültiger cloze-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: 'cloze',
|
|
fields: { text: 'Capital of {{c1::France}} is {{c2::Paris}}.' },
|
|
}),
|
|
});
|
|
expect(res.status).toBe(404);
|
|
const body = (await res.json()) as { error: string };
|
|
expect(body.error).toBe('deck_not_found');
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|