with some tests

This commit is contained in:
counterweight 2025-12-18 21:48:41 +01:00
parent a764c92a0b
commit 0995e1cc77
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
18 changed files with 3020 additions and 16 deletions

View file

@ -1,7 +1,7 @@
.PHONY: install-backend install-frontend install backend frontend
.PHONY: install-backend install-frontend install backend frontend db db-stop dev test test-frontend test-e2e
install-backend:
cd backend && uv sync
cd backend && uv sync --all-groups
install-frontend:
cd frontend && npm install
@ -14,3 +14,24 @@ backend:
frontend:
cd frontend && npm run dev
db:
docker compose up -d db
db-stop:
docker compose down
dev:
$(MAKE) db
cd backend && uv run uvicorn main:app --reload & \
cd frontend && npm run dev & \
wait
test-backend:
cd backend && uv run pytest -v
test-frontend:
cd frontend && npm run test
test-e2e:
./scripts/e2e.sh

18
backend/database.py Normal file
View file

@ -0,0 +1,18 @@
import os
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@localhost:5432/arbret")
engine = create_async_engine(DATABASE_URL)
async_session = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with async_session() as session:
yield session

View file

@ -1,7 +1,21 @@
from fastapi import FastAPI
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
app = FastAPI()
from database import engine, get_db, Base
from models import Counter
@asynccontextmanager
async def lifespan(app: FastAPI):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
app = FastAPI(lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
@ -11,7 +25,27 @@ app.add_middleware(
)
@app.get("/api/hello")
def hello():
return {"message": "Hello from FastAPI"}
async def get_or_create_counter(db: AsyncSession) -> Counter:
result = await db.execute(select(Counter).where(Counter.id == 1))
counter = result.scalar_one_or_none()
if not counter:
counter = Counter(id=1, value=0)
db.add(counter)
await db.commit()
await db.refresh(counter)
return counter
@app.get("/api/counter")
async def get_counter(db: AsyncSession = Depends(get_db)):
counter = await get_or_create_counter(db)
return {"value": counter.value}
@app.post("/api/counter/increment")
async def increment_counter(db: AsyncSession = Depends(get_db)):
counter = await get_or_create_counter(db)
counter.value += 1
await db.commit()
return {"value": counter.value}

11
backend/models.py Normal file
View file

@ -0,0 +1,11 @@
from sqlalchemy import Integer
from sqlalchemy.orm import Mapped, mapped_column
from database import Base
class Counter(Base):
__tablename__ = "counter"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
value: Mapped[int] = mapped_column(Integer, default=0)

View file

@ -5,5 +5,15 @@ requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115.6",
"uvicorn>=0.34.0",
"sqlalchemy[asyncio]>=2.0.36",
"asyncpg>=0.30.0",
]
[dependency-groups]
dev = [
"pytest>=8.3.4",
"pytest-asyncio>=0.25.0",
"httpx>=0.28.1",
"aiosqlite>=0.20.0",
]

4
backend/pytest.ini Normal file
View file

@ -0,0 +1,4 @@
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function

View file

View file

@ -0,0 +1,60 @@
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from database import Base, get_db
from main import app
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest.fixture
async def client():
engine = create_async_engine(TEST_DATABASE_URL)
async_session = async_sessionmaker(engine, expire_on_commit=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def override_get_db():
async with async_session() as session:
yield session
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
await engine.dispose()
@pytest.mark.asyncio
async def test_get_counter_initial(client):
response = await client.get("/api/counter")
assert response.status_code == 200
assert response.json() == {"value": 0}
@pytest.mark.asyncio
async def test_increment_counter(client):
response = await client.post("/api/counter/increment")
assert response.status_code == 200
assert response.json() == {"value": 1}
@pytest.mark.asyncio
async def test_increment_counter_multiple(client):
await client.post("/api/counter/increment")
await client.post("/api/counter/increment")
response = await client.post("/api/counter/increment")
assert response.json() == {"value": 3}
@pytest.mark.asyncio
async def test_get_counter_after_increment(client):
await client.post("/api/counter/increment")
await client.post("/api/counter/increment")
response = await client.get("/api/counter")
assert response.json() == {"value": 2}

15
docker-compose.yml Normal file
View file

@ -0,0 +1,15 @@
services:
db:
image: postgres:17
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: arbret
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:

View file

@ -0,0 +1,68 @@
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { expect, test, vi, beforeEach, afterEach } from "vitest";
import Home from "./page";
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
cleanup();
});
test("renders loading state initially", () => {
vi.spyOn(global, "fetch").mockImplementation(() => new Promise(() => {}));
render(<Home />);
expect(screen.getByText("...")).toBeDefined();
});
test("renders counter value after fetch", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
json: () => Promise.resolve({ value: 42 }),
} as Response);
render(<Home />);
await waitFor(() => {
expect(screen.getByText("42")).toBeDefined();
});
});
test("renders +1 button", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
json: () => Promise.resolve({ value: 0 }),
} as Response);
render(<Home />);
expect(screen.getByText("+1")).toBeDefined();
});
test("clicking button calls increment endpoint", async () => {
const fetchSpy = vi.spyOn(global, "fetch")
.mockResolvedValueOnce({ json: () => Promise.resolve({ value: 0 }) } as Response)
.mockResolvedValueOnce({ json: () => Promise.resolve({ value: 1 }) } as Response);
render(<Home />);
await waitFor(() => expect(screen.getByText("0")).toBeDefined());
fireEvent.click(screen.getByText("+1"));
await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledWith(
"http://localhost:8000/api/counter/increment",
{ method: "POST" }
);
});
});
test("clicking button updates displayed count", async () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({ json: () => Promise.resolve({ value: 0 }) } as Response)
.mockResolvedValueOnce({ json: () => Promise.resolve({ value: 1 }) } as Response);
render(<Home />);
await waitFor(() => expect(screen.getByText("0")).toBeDefined());
fireEvent.click(screen.getByText("+1"));
await waitFor(() => expect(screen.getByText("1")).toBeDefined());
});

View file

@ -3,18 +3,37 @@
import { useEffect, useState } from "react";
export default function Home() {
const [message, setMessage] = useState("");
const [count, setCount] = useState<number | null>(null);
useEffect(() => {
fetch("http://localhost:8000/api/hello")
fetch("http://localhost:8000/api/counter")
.then((res) => res.json())
.then((data) => setMessage(data.message));
.then((data) => setCount(data.value));
}, []);
const increment = async () => {
const res = await fetch("http://localhost:8000/api/counter/increment", {
method: "POST",
});
const data = await res.json();
setCount(data.value);
};
return (
<main style={{ padding: "2rem", fontFamily: "system-ui" }}>
<h1>FastAPI + Next.js</h1>
<p>{message || "Loading..."}</p>
<main style={{ padding: "2rem", fontFamily: "system-ui", textAlign: "center" }}>
<h1 style={{ fontSize: "6rem", margin: "2rem 0" }}>
{count === null ? "..." : count}
</h1>
<button
onClick={increment}
style={{
fontSize: "1.5rem",
padding: "1rem 2rem",
cursor: "pointer",
}}
>
+1
</button>
</main>
);
}

View file

@ -0,0 +1,29 @@
import { test, expect } from "@playwright/test";
test("displays counter value", async ({ page }) => {
await page.goto("/");
await expect(page.locator("h1")).not.toHaveText("...");
});
test("clicking +1 increments the counter", async ({ page }) => {
await page.goto("/");
await expect(page.locator("h1")).not.toHaveText("...");
const before = await page.locator("h1").textContent();
await page.click("button");
await expect(page.locator("h1")).toHaveText(String(Number(before) + 1));
});
test("counter persists after reload", async ({ page }) => {
await page.goto("/");
await expect(page.locator("h1")).not.toHaveText("...");
const before = await page.locator("h1").textContent();
await page.click("button");
const expected = String(Number(before) + 1);
await expect(page.locator("h1")).toHaveText(expected);
await page.reload();
await expect(page.locator("h1")).toHaveText(expected);
});

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,9 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
"start": "next start",
"test": "vitest run",
"test:e2e": "playwright test"
},
"dependencies": {
"next": "15.1.2",
@ -13,8 +15,13 @@
"react-dom": "19.0.0"
},
"devDependencies": {
"@playwright/test": "^1.49.1",
"@testing-library/react": "^16.1.0",
"@types/node": "25.0.3",
"@types/react": "19.2.7",
"typescript": "5.9.3"
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^26.0.0",
"typescript": "5.9.3",
"vitest": "^2.1.8"
}
}

View file

@ -0,0 +1,14 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: true,
},
use: {
baseURL: "http://localhost:3000",
},
});

View file

@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}

11
frontend/vitest.config.ts Normal file
View file

@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
include: ["app/**/*.test.{ts,tsx}"],
},
});

31
scripts/e2e.sh Executable file
View file

@ -0,0 +1,31 @@
#!/bin/bash
set -e
cd "$(dirname "$0")/.."
# Kill any existing backend
pkill -f "uvicorn main:app" 2>/dev/null || true
sleep 1
# Start db
docker compose up -d db
# Start backend
cd backend
uv run uvicorn main:app --port 8000 &
PID=$!
cd ..
# Wait for backend
sleep 2
# Run tests
cd frontend
npm run test:e2e
EXIT_CODE=$?
# Cleanup
kill $PID 2>/dev/null || true
exit $EXIT_CODE