Compare commits

...

2 commits

Author SHA1 Message Date
2cae5d9445 feat: add the 'tests' module to the project
Some checks failed
/ test (push) Has been cancelled
2026-04-07 07:55:57 +01:00
acadf77e2e feat: Split sentence/translation in UI into a component 2026-04-07 07:09:25 +01:00
10 changed files with 532 additions and 105 deletions

View file

@ -0,0 +1,24 @@
on:
push:
branches:
- "main"
pull_request:
types: [opened, synchronize, reopened]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install pytest
run: pip install pytest
- name: Run tests
working-directory: test
run: docker-compose up -d && pytest -v

View file

@ -32,6 +32,11 @@ class SpacyClient:
"""Use SpaCy to get parts of speech for the given text and language, """Use SpaCy to get parts of speech for the given text and language,
broken down by sentences and then by tokens.""" broken down by sentences and then by tokens."""
nlp = self._get_nlp(language) nlp = self._get_nlp(language)
# Recognise line-breaks as always being sentence boundaries, even if the model doesn't.
# This is important for the frontend to be able to show line-breaks in the source text.
nlp.add_pipe("sentencizer", before="parser")
doc = nlp(text) doc = nlp(text)
sentences = [ sentences = [

59
docker-compose.test.yml Normal file
View file

@ -0,0 +1,59 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: langlearn_test
POSTGRES_PASSWORD: testpassword
POSTGRES_DB: langlearn_test
tmpfs:
- /var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U langlearn_test"]
interval: 5s
timeout: 5s
retries: 10
storage:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: langlearn_test
MINIO_ROOT_PASSWORD: testpassword123
tmpfs:
- /data
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:9000/minio/health/live || exit 1"]
interval: 5s
timeout: 5s
retries: 10
api:
build: ./api
ports:
- "18000:8000"
environment:
DATABASE_URL: postgresql+asyncpg://langlearn_test:testpassword@db:5432/langlearn_test
JWT_SECRET: test-jwt-secret-not-for-production
ANTHROPIC_API_KEY: test-key
DEEPL_API_KEY: test-key
DEEPGRAM_API_KEY: test-key
GEMINI_API_KEY: test-key
ADMIN_USER_EMAILS: admin@test.com
API_BASE_URL: http://localhost:18000
STORAGE_ENDPOINT_URL: http://storage:9000
STORAGE_ACCESS_KEY: langlearn_test
STORAGE_SECRET_KEY: testpassword123
STORAGE_BUCKET: langlearn-test
depends_on:
db:
condition: service_healthy
storage:
condition: service_healthy
healthcheck:
test:
- "CMD-SHELL"
- "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""
interval: 5s
timeout: 5s
retries: 20
start_period: 10s

View file

@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import type { PartsOfSpeechData } from '$lib/spacy/types'; import type { PartOfSpeechToken, PartsOfSpeechData } from '$lib/spacy/types';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import TargetLanguageBody from './TargetLanguageBody.svelte'; import TargetLanguageBody from './TargetLanguageBody.svelte';
import type { Paragraph, Sentence, SentenceToken, Transcript } from './Transcript'; import type { Paragraph, Sentence, SentenceToken, Transcript } from './Transcript';
import TranslationPanel from './TranslationPanel.svelte'; import TranslationPanel from './TranslationPanel.svelte';
import { translateText } from './translate.remote';
const { data }: PageProps = $props(); const { data }: PageProps = $props();
const { article } = data; const { article } = data;
@ -35,10 +34,10 @@
}; };
const sentenceEndsWithNewLine = s.text.endsWith('\n'); const sentenceEndsWithNewLine = s.text.endsWith('\n');
paragraphs[paragraphs.length - 1].sentences.push(sentence);
if (sentenceEndsWithNewLine) { if (sentenceEndsWithNewLine) {
paragraphs.push({ index: paragraphs.length, sentences: [] }); paragraphs.push({ index: paragraphs.length, sentences: [] });
} else {
paragraphs[paragraphs.length - 1].sentences.push(sentence);
} }
}); });
@ -49,6 +48,16 @@
article.target_body_pos as Record<string, any> as PartsOfSpeechData article.target_body_pos as Record<string, any> as PartsOfSpeechData
); );
// Flat source-sentence list, aligned by sentence index to the target sentences.
// Used by TranslationPanel to show the source-language context for guessing.
const sourceSentences: Array<{ text: string; tokens: PartOfSpeechToken[] }> = (() => {
try {
return (article.source_body_pos as Record<string, any> as PartsOfSpeechData).sentences ?? [];
} catch {
return [];
}
})();
// Flat sentence list for O(n) audio-time lookup // Flat sentence list for O(n) audio-time lookup
const allSentences: Array<{ idx: number; startWordIdx: number; endWordIdx: number }> = []; const allSentences: Array<{ idx: number; startWordIdx: number; endWordIdx: number }> = [];
for (const para of paragraphs) { for (const para of paragraphs) {
@ -88,10 +97,10 @@
let audioEl: HTMLAudioElement | null = $state(null); let audioEl: HTMLAudioElement | null = $state(null);
let activeSentenceIdx = $state(-1); let activeSentenceIdx = $state(-1);
let selectedSentenceToken: SentenceToken | null = $state(null); let selectedTokens: SentenceToken[] = $state([]);
let selectedSentence: Sentence | null = $state(null); let selectedSentence: Sentence | null = $state(null);
let translatedText: string | null = $state(null);
let translating = $state(false); const selectedTokenIndices = $derived(new Set(selectedTokens.map((t) => t.idx)));
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Audio: sentence highlighting // Audio: sentence highlighting
@ -125,35 +134,18 @@
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Word click: fetch translation // Word selection: open panel with sentence context
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
async function handleWordClick(token: SentenceToken, sentence: Sentence) { function handleSelection(tokens: SentenceToken[], sentence: Sentence) {
selectedSentenceToken = token; selectedTokens = tokens;
selectedSentence = sentence;
activeSentenceIdx = sentence.idx; activeSentenceIdx = sentence.idx;
translatedText = null;
translating = true;
try {
const result = await translateText({
fromLanguage: article.target_language,
toLanguage: article.source_language,
sentenceText: sentence.text,
text: token.text
});
translatedText = result.text;
console.log({ result });
} catch {
translatedText = null;
} finally {
translating = false;
}
} }
function closePanel() { function closePanel() {
selectedSentenceToken = null; selectedTokens = [];
translatedText = null; selectedSentence = null;
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -216,12 +208,19 @@
lang={article.source_language} lang={article.source_language}
{paragraphs} {paragraphs}
{activeSentenceIdx} {activeSentenceIdx}
onWordClick={handleWordClick} onSelection={handleSelection}
{selectedSentenceToken} {selectedTokenIndices}
/> />
</div> </div>
<TranslationPanel {closePanel} {selectedSentenceToken} {translatedText} {translating} /> <TranslationPanel
{closePanel}
{selectedTokens}
targetSentence={selectedSentence}
sourceTokens={selectedSentence !== null
? (sourceSentences[selectedSentence.idx]?.tokens ?? null)
: null}
/>
</div> </div>
</div> </div>
@ -229,7 +228,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div <div
class="drawer-backdrop" class="drawer-backdrop"
class:is-visible={selectedSentenceToken !== null} class:is-visible={selectedTokens.length > 0}
onclick={closePanel} onclick={closePanel}
aria-hidden="true" aria-hidden="true"
></div> ></div>

View file

@ -4,16 +4,156 @@
interface Props { interface Props {
paragraphs: Paragraph[]; paragraphs: Paragraph[];
activeSentenceIdx: number; activeSentenceIdx: number;
selectedSentenceToken: SentenceToken | null; selectedTokenIndices: Set<number>;
onWordClick: (token: SentenceToken, sentence: Sentence) => void; onSelection: (tokens: SentenceToken[], sentence: Sentence) => void;
lang: string; lang: string;
} }
const { paragraphs, activeSentenceIdx, selectedSentenceToken, onWordClick, lang }: Props = const { paragraphs, activeSentenceIdx, selectedTokenIndices, onSelection, lang }: Props =
$props(); $props();
// Flat map: token.idx → { token, sentence } used during drag finalisation
const tokenMap = new Map<number, { token: SentenceToken; sentence: Sentence }>();
for (const para of paragraphs) {
for (const sentence of para.sentences) {
for (const token of sentence.tokens) {
tokenMap.set(token.idx, { token, sentence });
}
}
}
// -------------------------------------------------------------------------
// Drag-selection state
// -------------------------------------------------------------------------
type DragState = {
startTokenIdx: number;
endTokenIdx: number;
sentenceIdx: number;
startX: number;
startY: number;
pointerId: number;
committed: boolean; // true once we've locked in as a drag (not a tap)
};
let drag = $state<DragState | null>(null);
let containerEl: HTMLDivElement;
// The token-index range highlighted during an active drag
const dragRange = $derived(
drag?.committed
? {
min: Math.min(drag.startTokenIdx, drag.endTokenIdx),
max: Math.max(drag.startTokenIdx, drag.endTokenIdx)
}
: null
);
// After a committed drag, suppress the click event the browser fires next
let suppressNextClick = false;
function wordElAt(target: EventTarget | null) {
return (target as Element | null)?.closest<HTMLElement>('[data-token-idx]') ?? null;
}
function handlePointerDown(e: PointerEvent) {
const wordEl = wordElAt(e.target);
if (!wordEl) return;
drag = {
startTokenIdx: parseInt(wordEl.dataset.tokenIdx!),
endTokenIdx: parseInt(wordEl.dataset.tokenIdx!),
sentenceIdx: parseInt(wordEl.dataset.sentenceIdx!),
startX: e.clientX,
startY: e.clientY,
pointerId: e.pointerId,
committed: false
};
}
function handlePointerMove(e: PointerEvent) {
if (!drag || e.pointerId !== drag.pointerId) return;
const dx = e.clientX - drag.startX;
const dy = e.clientY - drag.startY;
if (!drag.committed) {
// Commit to drag only when horizontal movement clearly dominates
if (Math.abs(dx) > 12 && Math.abs(dx) > Math.abs(dy) * 1.5) {
drag.committed = true;
containerEl.setPointerCapture(e.pointerId);
} else if (Math.abs(dy) > 12) {
// Vertical scroll intent — abandon without selection
drag = null;
}
return;
}
// Drag is committed: find the word under the pointer and update end token.
// document.elementFromPoint is unaffected by pointer capture and returns
// the visually topmost element at (x, y).
const el = document.elementFromPoint(e.clientX, e.clientY);
const wordEl = el?.closest<HTMLElement>('[data-token-idx]');
if (wordEl) {
const newSentenceIdx = parseInt(wordEl.dataset.sentenceIdx!);
// Constrain selection to the sentence the drag started in
if (newSentenceIdx === drag.sentenceIdx) {
drag.endTokenIdx = parseInt(wordEl.dataset.tokenIdx!);
}
}
}
function handlePointerUp(e: PointerEvent) {
if (!drag || e.pointerId !== drag.pointerId) return;
const { startTokenIdx, endTokenIdx, committed } = drag;
drag = null;
const entry = tokenMap.get(startTokenIdx);
if (!entry) return;
if (committed) {
suppressNextClick = true;
const sentence = entry.sentence;
const min = Math.min(startTokenIdx, endTokenIdx);
const max = Math.max(startTokenIdx, endTokenIdx);
const tokens = sentence.tokens.filter((t) => !t.is_punct && t.idx >= min && t.idx <= max);
if (tokens.length > 0) onSelection(tokens, sentence);
}
// Simple tap falls through to the click event handled below
}
function handlePointerCancel(e: PointerEvent) {
if (drag?.pointerId === e.pointerId) drag = null;
}
// Handles both mouse clicks and keyboard activation (Enter/Space on a focused word)
function handleClick(e: MouseEvent) {
if (suppressNextClick) {
suppressNextClick = false;
return;
}
const wordEl = wordElAt(e.target);
if (!wordEl) return;
const entry = tokenMap.get(parseInt(wordEl.dataset.tokenIdx!));
if (entry && !entry.token.is_punct) {
onSelection([entry.token], entry.sentence);
}
}
</script> </script>
<div class="article-body" {lang}> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="article-body"
{lang}
bind:this={containerEl}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={handlePointerCancel}
onclick={handleClick}
role="none"
>
{#each paragraphs as para (para.index)} {#each paragraphs as para (para.index)}
<p class="paragraph"> <p class="paragraph">
{#each para.sentences as sentence (sentence.idx)} {#each para.sentences as sentence (sentence.idx)}
@ -22,8 +162,13 @@
{#if !token.is_punct} {#if !token.is_punct}
<button <button
class="word" class="word"
class:word--selected={selectedSentenceToken?.idx === token.idx} class:word--selected={selectedTokenIndices.has(token.idx) ||
onclick={() => onWordClick(token, sentence)} (dragRange !== null &&
token.idx >= dragRange.min &&
token.idx <= dragRange.max)}
data-token-idx={token.idx}
data-sentence-idx={sentence.idx}
tabindex="0"
> >
{token.text} {token.text}
</button> </button>
@ -43,6 +188,9 @@
flex-direction: column; flex-direction: column;
gap: var(--space-4); gap: var(--space-4);
font-family: var(--font-body); font-family: var(--font-body);
/* Allow vertical scroll gestures to pass through; horizontal drags are
intercepted by our pointer handlers for word selection. */
touch-action: pan-y;
} }
.paragraph { .paragraph {
@ -76,6 +224,9 @@
transition: transition:
background-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard); color var(--duration-fast) var(--ease-standard);
/* Prevent text selection highlight during drag */
user-select: none;
-webkit-user-select: none;
} }
.word:hover { .word:hover {

View file

@ -1,58 +1,101 @@
<script lang="ts"> <script lang="ts">
import type { SentenceToken } from './Transcript'; import type { PartOfSpeechToken } from '$lib/spacy/types';
import type { Sentence, SentenceToken } from './Transcript';
let { let {
selectedSentenceToken = null, selectedTokens = [],
translating = false, targetSentence = null,
translatedText = '', sourceTokens = null,
closePanel closePanel
}: { }: {
selectedSentenceToken?: SentenceToken | null; selectedTokens?: SentenceToken[];
translating?: boolean; targetSentence?: Sentence | null;
translatedText: string | null; sourceTokens?: PartOfSpeechToken[] | null;
closePanel: () => void; closePanel: () => void;
} = $props(); } = $props();
// Which source-language word indices the user has tapped as their guess
let guessedIndices = $state(new Set<number>());
// Reset guess whenever the selection changes
$effect(() => {
selectedTokens; // reactive dependency — any new selection clears the guess
guessedIndices = new Set();
});
const isOpen = $derived(selectedTokens.length > 0);
// Title: the selected word(s) joined by space
const selectionLabel = $derived(selectedTokens.map((t) => t.text).join(' '));
// Set of target token indices for fast lookup in the sentence display
const selectedTargetIndices = $derived(new Set(selectedTokens.map((t) => t.idx)));
function toggleGuess(i: number) {
const next = new Set(guessedIndices);
if (next.has(i)) {
next.delete(i);
} else {
next.add(i);
}
guessedIndices = next;
}
</script> </script>
<!-- Translation panel (desktop: sticky sidebar; mobile: bottom drawer) --> <!-- Translation panel (desktop: sticky sidebar; mobile: bottom drawer) -->
<aside <aside class="translation-panel" class:is-open={isOpen} aria-label="Word translation">
class="translation-panel" {#if isOpen}
class:is-open={selectedSentenceToken !== null}
aria-label="Word translation"
>
{#if selectedSentenceToken}
<div class="panel-header"> <div class="panel-header">
<p class="panel-word">{selectedSentenceToken.text}</p> <p class="panel-word">{selectionLabel}</p>
<button class="btn btn-ghost panel-close" onclick={closePanel} aria-label="Close panel"> <button class="btn btn-ghost panel-close" onclick={closePanel} aria-label="Close panel">
</button> </button>
</div> </div>
{#if translating} {#if targetSentence}
<div class="panel-loading"> <!-- Target-language sentence with the selection highlighted -->
<div class="spinner" aria-hidden="true"></div> <p class="panel-sentence panel-sentence--target" lang="und">
<span>Translating…</span> {#each targetSentence.tokens as token (token.idx)}
</div> {#if !token.is_punct}
{:else if translatedText} <span
<p class="panel-translation">{translatedText}</p> class="sentence-word"
<button class="btn btn-secondary panel-save" disabled aria-disabled="true"> class:sentence-word--selected={selectedTargetIndices.has(token.idx)}
Add to flashcard >
{token.text}
</span>
{:else}
<span class="sentence-punct">{token.text}</span>
{/if}
{/each}
</p>
{/if}
{#if sourceTokens && sourceTokens.length > 0}
<p class="panel-prompt">Which word(s) do you think it means?</p>
<!-- Source-language sentence — tap words to mark your guess -->
<p class="panel-sentence panel-sentence--source" lang="und">
{#each sourceTokens as token, i (i)}
{#if !token.is_punct}
<button
class="source-word"
class:source-word--guessed={guessedIndices.has(i)}
onclick={() => toggleGuess(i)}
>
{token.text}
</button> </button>
{:else} {:else}
<p class="panel-error">Could not load translation.</p> <span class="sentence-punct">{token.text}</span>
{/if}
{/each}
</p>
{/if} {/if}
{:else} {:else}
<p class="panel-hint">Tap any word for a translation</p> <p class="panel-hint">Select a word — or drag across several — to see it in context</p>
{/if} {/if}
</aside> </aside>
<style> <style>
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.panel-header { .panel-header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@ -76,19 +119,72 @@
line-height: 1; line-height: 1;
} }
.panel-translation { /* --- Sentence displays --- */
.panel-sentence {
font-family: var(--font-body); font-family: var(--font-body);
font-size: var(--text-body-lg); font-size: var(--text-body-lg);
line-height: var(--leading-relaxed); line-height: var(--leading-relaxed);
color: var(--color-on-surface-variant); color: var(--color-on-surface);
font-style: italic; }
.panel-sentence--target {
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
} }
.panel-save { .panel-sentence--source {
width: 100%; margin-top: var(--space-2);
padding-block: var(--space-2); }
opacity: 0.6;
.sentence-word {
/* inline, non-interactive — just for highlighting */
}
.sentence-word--selected {
background-color: color-mix(in srgb, var(--color-primary) 20%, transparent);
color: var(--color-primary);
font-weight: var(--weight-semibold);
border-radius: var(--radius-xs);
padding: 0 0.1em;
}
.sentence-punct {
color: var(--color-on-surface-variant);
}
/* --- Prompt --- */
.panel-prompt {
font-family: var(--font-label);
font-size: var(--text-body-sm);
color: var(--color-on-surface-variant);
margin-bottom: var(--space-2);
}
/* --- Source-language word buttons (guess targets) --- */
.source-word {
display: inline;
background: none;
border: none;
padding: 0 0.1em;
margin: 0;
font: inherit;
font-size: var(--text-body-lg);
color: inherit;
cursor: pointer;
border-radius: var(--radius-xs);
transition:
background-color var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard);
}
.source-word:hover {
background-color: color-mix(in srgb, var(--color-secondary) 12%, transparent);
color: var(--color-secondary);
}
.source-word--guessed {
background-color: color-mix(in srgb, var(--color-secondary) 20%, transparent);
color: var(--color-secondary);
font-weight: var(--weight-medium);
} }
.panel-hint { .panel-hint {
@ -99,30 +195,6 @@
padding: var(--space-4) 0; padding: var(--space-4) 0;
} }
.panel-error {
font-family: var(--font-label);
font-size: var(--text-body-sm);
color: var(--color-on-surface-variant);
}
.panel-loading {
display: flex;
align-items: center;
gap: var(--space-3);
font-family: var(--font-label);
font-size: var(--text-body-sm);
color: var(--color-on-surface-variant);
}
.spinner {
flex-shrink: 0;
width: 1rem;
height: 1rem;
border: 2px solid var(--color-outline-variant);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
/* --- Translation panel: Desktop (sticky sidebar) --- */ /* --- Translation panel: Desktop (sticky sidebar) --- */
@media (min-width: 768px) { @media (min-width: 768px) {
.translation-panel { .translation-panel {
@ -147,7 +219,7 @@
background-color: var(--color-surface-container-lowest); background-color: var(--color-surface-container-lowest);
border-radius: var(--radius-xl) var(--radius-xl) 0 0; border-radius: var(--radius-xl) var(--radius-xl) 0 0;
padding: var(--space-5) var(--space-5) calc(var(--space-5) + env(safe-area-inset-bottom)); padding: var(--space-5) var(--space-5) calc(var(--space-5) + env(safe-area-inset-bottom));
max-height: 55vh; max-height: 70vh;
overflow-y: auto; overflow-y: auto;
transform: translateY(100%); transform: translateY(100%);
transition: transform var(--duration-slow) var(--ease-standard); transition: transform var(--duration-slow) var(--ease-standard);

5
tests/README.md Normal file
View file

@ -0,0 +1,5 @@
# Tests
This module contains system-level tests for the Langauge Learning App, e.g. end-to-end (e2e) or API-level tests.
Because the whole system relies on multiple components (api, frontend, storage, db), it can be efficient to centralise these tests into a single module, rather than having each module re-create them. Additionally, it is not the responsibility of any single feature module to assert the correct behaviour of the entire cohort.

42
tests/conftest.py Normal file
View file

@ -0,0 +1,42 @@
"""
Session-scoped fixtures that spin up and tear down the test stack.
The test stack uses docker-compose.test.yml which:
- Runs on port 18000 (won't collide with the dev stack on 8000)
- Uses tmpfs for all storage (no data survives after `down`)
- Uses project name "langlearn-test" to stay isolated from dev containers
"""
import pathlib
import subprocess
import httpx
import pytest
PROJECT_ROOT = pathlib.Path(__file__).parent.parent
COMPOSE_FILE = str(PROJECT_ROOT / "docker-compose.test.yml")
COMPOSE_PROJECT = "langlearn-test"
API_BASE_URL = "http://localhost:18000"
def _compose(*args: str) -> None:
subprocess.run(
["docker", "compose", "-p", COMPOSE_PROJECT, "-f", COMPOSE_FILE, *args],
cwd=PROJECT_ROOT,
check=True,
)
@pytest.fixture(scope="session", autouse=True)
def docker_stack():
"""Bring the test stack up before the session; tear it down (including volumes) after."""
_compose("up", "--build", "--wait", "-d")
yield
_compose("down", "-v")
@pytest.fixture
def client() -> httpx.Client:
"""A plain httpx client pointed at the test API. Not authenticated."""
with httpx.Client(base_url=API_BASE_URL) as c:
yield c

11
tests/pyproject.toml Normal file
View file

@ -0,0 +1,11 @@
[project]
name = "language-learning-api-tests"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"pytest>=8.0.0",
"httpx>=0.28.1",
]
[tool.pytest.ini_options]
testpaths = ["."]

59
tests/test_auth.py Normal file
View file

@ -0,0 +1,59 @@
import httpx
import pytest
def test_register_creates_account(client: httpx.Client):
response = client.post(
"/auth/register",
json={"email": "newuser@example.com", "password": "securepassword123"},
)
assert response.status_code == 201
body = response.json()
assert body["email"] == "newuser@example.com"
assert "id" in body
def test_register_duplicate_email_returns_409(client: httpx.Client):
payload = {"email": "duplicate@example.com", "password": "securepassword123"}
client.post("/auth/register", json=payload)
response = client.post("/auth/register", json=payload)
assert response.status_code == 409
def test_login_returns_token(client: httpx.Client):
credentials = {"email": "loginuser@example.com", "password": "securepassword123"}
client.post("/auth/register", json=credentials)
response = client.post("/auth/login", json=credentials)
assert response.status_code == 200
body = response.json()
assert "access_token" in body
assert body["token_type"] == "bearer"
assert len(body["access_token"]) > 0
def test_login_wrong_password_returns_401(client: httpx.Client):
client.post(
"/auth/register",
json={"email": "wrongpass@example.com", "password": "correctpassword"},
)
response = client.post(
"/auth/login",
json={"email": "wrongpass@example.com", "password": "wrongpassword"},
)
assert response.status_code == 401
def test_login_unknown_email_returns_401(client: httpx.Client):
response = client.post(
"/auth/login",
json={"email": "nobody@example.com", "password": "doesntmatter"},
)
assert response.status_code == 401