with some tests
This commit is contained in:
parent
a764c92a0b
commit
0995e1cc77
18 changed files with 3020 additions and 16 deletions
25
Makefile
25
Makefile
|
|
@ -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:
|
install-backend:
|
||||||
cd backend && uv sync
|
cd backend && uv sync --all-groups
|
||||||
|
|
||||||
install-frontend:
|
install-frontend:
|
||||||
cd frontend && npm install
|
cd frontend && npm install
|
||||||
|
|
@ -14,3 +14,24 @@ backend:
|
||||||
frontend:
|
frontend:
|
||||||
cd frontend && npm run dev
|
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
18
backend/database.py
Normal 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
|
||||||
|
|
||||||
|
|
@ -1,7 +1,21 @@
|
||||||
from fastapi import FastAPI
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI, Depends
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|
@ -11,7 +25,27 @@ app.add_middleware(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/hello")
|
async def get_or_create_counter(db: AsyncSession) -> Counter:
|
||||||
def hello():
|
result = await db.execute(select(Counter).where(Counter.id == 1))
|
||||||
return {"message": "Hello from FastAPI"}
|
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
11
backend/models.py
Normal 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)
|
||||||
|
|
||||||
|
|
@ -5,5 +5,15 @@ requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.115.6",
|
"fastapi>=0.115.6",
|
||||||
"uvicorn>=0.34.0",
|
"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
4
backend/pytest.ini
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
asyncio_default_fixture_loop_scope = function
|
||||||
|
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
60
backend/tests/test_counter.py
Normal file
60
backend/tests/test_counter.py
Normal 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
15
docker-compose.yml
Normal 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:
|
||||||
|
|
||||||
68
frontend/app/page.test.tsx
Normal file
68
frontend/app/page.test.tsx
Normal 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());
|
||||||
|
});
|
||||||
|
|
||||||
|
|
@ -3,18 +3,37 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [message, setMessage] = useState("");
|
const [count, setCount] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("http://localhost:8000/api/hello")
|
fetch("http://localhost:8000/api/counter")
|
||||||
.then((res) => res.json())
|
.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 (
|
return (
|
||||||
<main style={{ padding: "2rem", fontFamily: "system-ui" }}>
|
<main style={{ padding: "2rem", fontFamily: "system-ui", textAlign: "center" }}>
|
||||||
<h1>FastAPI + Next.js</h1>
|
<h1 style={{ fontSize: "6rem", margin: "2rem 0" }}>
|
||||||
<p>{message || "Loading..."}</p>
|
{count === null ? "..." : count}
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
onClick={increment}
|
||||||
|
style={{
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
padding: "1rem 2rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+1
|
||||||
|
</button>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
frontend/e2e/counter.spec.ts
Normal file
29
frontend/e2e/counter.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
|
||||||
2650
frontend/package-lock.json
generated
2650
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -5,7 +5,9 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start"
|
"start": "next start",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "15.1.2",
|
"next": "15.1.2",
|
||||||
|
|
@ -13,8 +15,13 @@
|
||||||
"react-dom": "19.0.0"
|
"react-dom": "19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.49.1",
|
||||||
|
"@testing-library/react": "^16.1.0",
|
||||||
"@types/node": "25.0.3",
|
"@types/node": "25.0.3",
|
||||||
"@types/react": "19.2.7",
|
"@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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
frontend/playwright.config.ts
Normal file
14
frontend/playwright.config.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
4
frontend/test-results/.last-run.json
Normal file
4
frontend/test-results/.last-run.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
11
frontend/vitest.config.ts
Normal file
11
frontend/vitest.config.ts
Normal 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
31
scripts/e2e.sh
Executable 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
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue