Lägg till mer tester/workflow

This commit is contained in:
jwradhe 2025-12-22 22:12:19 +01:00
parent 8491c05ce6
commit 3d1e21ccd9
25 changed files with 2628 additions and 56 deletions

View File

@ -5,3 +5,6 @@ venv/
.git/
.DS_Store
todo.db
node_modules/
test-results/
*.db

View File

@ -5,22 +5,51 @@ on:
pull_request:
jobs:
pytest:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
steps:
- name: Checkout code
uses: actions/checkout@v4
# Python setup
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
- name: Install Python dependencies
run: |
python -m pip install -U pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
pip install pytest
- name: Run tests
# Node setup (Newman + Playwright)
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install Node dependencies
run: npm install
- name: Install Playwright browsers
run: npx playwright install --with-deps
# Start Flask server
- name: Start Flask server
run: |
pytest -q
flask --app app:create_app run --port 5001 &
sleep 5
# Run tests
- name: Run unit & integration tests (pytest)
run: pytest -q
- name: Run API tests (Newman)
run: npm run api-test
- name: Run E2E tests (Playwright)
run: npm run e2e

26
.gitignore vendored
View File

@ -1,4 +1,6 @@
# Python bytecode
# =========================
# Python
# =========================
__pycache__/
*.py[cod]
*$py.class
@ -7,11 +9,29 @@ __pycache__/
venv/
.env/
# Pytest
# Pytest / coverage
.pytest_cache/
.coverage
htmlcov/
# =========================
# SQLite
# =========================
*.db
# OS
# =========================
# Node / Frontend tooling
# =========================
node_modules/
npm-debug.log*
yarn-error.log*
# Playwright
test-results/
playwright-report/
# =========================
# OS / Editor
# =========================
.DS_Store
.vscode/

View File

@ -5,10 +5,12 @@ ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY requirements.txt .
RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt
COPY . .
COPY . /app
EXPOSE 5001
CMD ["python", "app.py"]
# Gunicorn: 2 workers räcker fint för en liten kursapp
CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:5001", "wsgi:app"]

View File

@ -1,7 +1,8 @@
# TODO Flask + SQLite
Ett enkelt TODO-system byggt med **Python**, **Flask** och **SQLite**.
Projektet är uppsatt med **app factory-pattern**, **pytest-tester** och är redo att köras i **GitHub Actions CI**.
Projektet använder **Flask app factory-pattern**, **pytest** för unit- och integrationstester samt **Postman/Newman** och **Playwright** för API- och E2E-tester.
Projektet är förberett för **CI (GitHub Actions)**.
---
@ -11,7 +12,7 @@ Projektet är uppsatt med **app factory-pattern**, **pytest-tester** och är red
- Uppdatera status (`not-started`, `in-progress`, `done`)
- Ta bort TODO-poster
- SQLite som databas
- Tester med pytest
- Tester på flera nivåer (unit, API, E2E)
- CI-redo (GitHub Actions)
---
@ -20,25 +21,41 @@ Projektet är uppsatt med **app factory-pattern**, **pytest-tester** och är red
```text
.
├── app.py # Flask app (app factory)
├── db.py # Databasfunktioner (SQLite)
├── todo.db # SQLite-databas (lokalt)
├── requirements.txt # Runtime dependencies
├── requirements-dev.txt # Dev/test dependencies
├── templates/
│ ├── base.html
│ └── index.html
├── static/
│ └── style.css
├── .github/
│ └── workflows/
│ └── tests.yml
├── app/
│ ├── __init__.py # Flask app (app factory)
│ ├── db.py # Databasfunktioner (SQLite)
│ ├── validation.py # Validering
│ ├── static/
│ │ └── style.css
│ ├── templates/
│ ├── base.html
│ └── index.html
├── tests/
│ └── test_app.py
├── Dockerfile
├── pytest.ini
├── requirements.txt
├── requirements-dev.txt
├── .gitignore
│ ├── api/
│ │ └── test_app.py
│ ├── e2e/
│ │ └── todo.spec.ts
│ ├── postman/
│ │ ├── todo.collection.json
│ │ └── todo.env.json
│ └── unit/
│ └── test_validation.py
├── .dockerignore
├── .gitignore
├── Dockerfile
├── LICENSE
├── package-lock.json
├── package.json
├── pytest.ini
└── README.md
├── requirements-dev.txt # Dev/test dependencies
├── requirements.txt # Runtime dependencies
├── todo.db # SQLite-databas (lokalt)
└── wsgi.py
```
## 🚀 Kom igång lokalt
@ -63,7 +80,7 @@ pip install -r requirements-dev.txt
### 4. Starta applikationen
```bash
python app.py
python3 app.py
```
### Öppna i webbläsaren:
@ -75,15 +92,25 @@ http://127.0.0.1:5001
## 🧪 Tester
Kör alla tester lokalt med pytest:
Unit- och integrationstester:
```bash
pytest
```
Med kodtäckning(coverage)
API-tester (Postman / Newman):
```bash
pytest --cov=app --cov-report=term-missing --cov-fail-under=80
npm install
npm run api-test
```
End-to-End tester (Playwright):
```bash
npm run e2e
```
Köra alla tester:
```bash
npm run test:all
```
## 🤖 CI GitHub Actions
@ -104,5 +131,5 @@ Exempel (snabbstart):
```bash
docker build -t todo-app .
docker run -p 5001:5001 todo-app
docker run -d -p 5001:5001 todo-app
```

View File

View File

@ -1,8 +1,9 @@
import os
from datetime import datetime
from flask import Flask, render_template, redirect, url_for, request, flash
from app.validation import normalize_status, validate_todo_fields
from db import get_db, init_db
from app.db import get_db, init_db
# =====================================================
# Constants
@ -24,7 +25,7 @@ def create_app(test_config: dict | None = None) -> Flask:
# -------------------------------------------------
# App Configuration
# -------------------------------------------------
default_db = os.path.join(os.path.dirname(__file__), "todo.db")
default_db = os.path.join(os.path.dirname(os.path.dirname(__file__)), "todo.db")
app.config.update(
DB_PATH=default_db,
TESTING=False,
@ -81,15 +82,13 @@ def create_app(test_config: dict | None = None) -> Flask:
"""
title = (request.form.get("title") or "").strip()
description = (request.form.get("description") or "").strip()
status = request.form.get("status") or "not-started"
status = normalize_status(request.form.get("status", ""))
if not title or not description:
ok, errors = validate_todo_fields(title, description)
if not ok:
flash("Title och description krävs.", "danger")
return redirect(url_for("dashboard"))
if status not in STATUS_VALUES:
status = "not-started"
now = datetime.now().isoformat(timespec="seconds")
with get_db(app) as conn:

View File

20
app/validation.py Normal file
View File

@ -0,0 +1,20 @@
ALLOWED_STATUSES = {"not-started", "in-progress", "done"}
def normalize_status(status: str) -> str:
"""
Säkerställ att status alltid blir en av de tillåtna.
Om okänd -> default "not-started".
"""
status = (status or "").strip()
return status if status in ALLOWED_STATUSES else "not-started"
def validate_todo_fields(title: str, description: str) -> tuple[bool, list[str]]:
"""
Returnerar (ok, errors).
"""
errors = []
if not (title or "").strip():
errors.append("title_required")
if not (description or "").strip():
errors.append("description_required")
return (len(errors) == 0, errors)

2195
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "todo",
"version": "1.0.0",
"description": "Ett enkelt TODO-system byggt med **Python**, **Flask** och **SQLite**. Projektet är uppsatt med **app factory-pattern**, **pytest-tester** och är redo att köras i **GitHub Actions CI**.",
"main": "index.js",
"directories": {
"test": "tests"
},
"scripts": {
"start": "flask --app app:create_app run --port 5001",
"api-test": "newman run tests/postman/todo.collection.json -e tests/postman/todo.env.json",
"e2e": "playwright test",
"test:all": "start-server-and-test start http://127.0.0.1:5001/ \"npm run api-test && npm run e2e\""
},
"repository": {
"type": "git",
"url": "git+https://github.com/jwradhe/todo.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"bugs": {
"url": "https://github.com/jwradhe/todo/issues"
},
"homepage": "https://github.com/jwradhe/todo#readme",
"devDependencies": {
"@playwright/test": "^1.57.0",
"newman": "^6.1.0",
"start-server-and-test": "^2.1.3"
}
}

9
playwright.config.ts Normal file
View File

@ -0,0 +1,9 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
use: {
baseURL: process.env.BASE_URL || "http://127.0.0.1:5001",
headless: true
}
});

View File

@ -1,2 +1,2 @@
pytest>=8.0
pytest-cov>=5.0
pytest-cov>=5.0

View File

@ -1 +1,2 @@
Flask>=3.0
Flask>=3.0
gunicorn

View File

View File

@ -25,6 +25,7 @@ def fetch_all(db_path: str, sql: str, params=()):
# Tests
# =====================================================
# Test that the dashboard loads successfully
def test_dashboard_loads(tmp_path):
db_path = tmp_path / "test.db"
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
@ -33,7 +34,7 @@ def test_dashboard_loads(tmp_path):
resp = client.get("/dashboard")
assert resp.status_code == 200
# Test that creating a todo inserts a row in the database
def test_create_todo_inserts_row(tmp_path):
db_path = tmp_path / "test.db"
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
@ -49,7 +50,7 @@ def test_create_todo_inserts_row(tmp_path):
row = fetch_one(str(db_path), "SELECT title, description, status FROM todo")
assert row == ("Test", "Desc", "not-started")
# Test that creating a todo requires title and description
def test_create_requires_fields(tmp_path):
db_path = tmp_path / "test.db"
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
@ -65,7 +66,7 @@ def test_create_requires_fields(tmp_path):
row = fetch_one(str(db_path), "SELECT COUNT(*) FROM todo")
assert row[0] == 0
# Test that updating a todo's status works
def test_invalid_status_is_rejected_on_update(tmp_path):
db_path = tmp_path / "test.db"
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
@ -87,7 +88,7 @@ def test_invalid_status_is_rejected_on_update(tmp_path):
status = fetch_one(str(db_path), "SELECT status FROM todo WHERE id=?", (todo_id,))[0]
assert status == "not-started"
# Test that updating a todo's status to 'done' works
def test_update_status_to_done(tmp_path):
db_path = tmp_path / "test.db"
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
@ -107,7 +108,7 @@ def test_update_status_to_done(tmp_path):
assert status == "done"
assert edited is not None
# Test that deleting a todo removes it from the database
def test_delete_todo(tmp_path):
db_path = tmp_path / "test.db"
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
@ -125,6 +126,7 @@ def test_delete_todo(tmp_path):
row = fetch_one(str(db_path), "SELECT COUNT(*) FROM todo")
assert row[0] == 0
# Test that the dashboard lists created todos
def test_dashboard_lists_created_todo(tmp_path):
db_path = tmp_path / "test.db"
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
@ -136,7 +138,7 @@ def test_dashboard_lists_created_todo(tmp_path):
assert b"X" in resp.data
assert b"Y" in resp.data
# Test that creating a todo with invalid status defaults to 'not-started'
def test_create_invalid_status_defaults_to_not_started(tmp_path):
db_path = tmp_path / "test.db"
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
@ -146,7 +148,7 @@ def test_create_invalid_status_defaults_to_not_started(tmp_path):
row = fetch_one(str(db_path), "SELECT status FROM todo")
assert row[0] == "not-started"
# Test that deleting a nonexistent todo does not crash
def test_delete_nonexistent_does_not_crash(tmp_path):
db_path = tmp_path / "test.db"
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
@ -155,7 +157,7 @@ def test_delete_nonexistent_does_not_crash(tmp_path):
resp = client.post("/todo/999/delete", follow_redirects=True)
assert resp.status_code == 200
# Test that updating a nonexistent todo does not crash
def test_update_nonexistent_does_not_crash(tmp_path):
db_path = tmp_path / "test.db"
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})

144
tests/e2e/todo.spec.ts Normal file
View File

@ -0,0 +1,144 @@
import { test, expect } from "@playwright/test";
test.describe("Todo lifecycle E2E", () => {
test.describe.configure({ mode: "serial" });
const title = `E2E Todo ${Date.now()}`;
const description = "E2E Desc";
// Hitta create-formen
const getCreateForm = (page: any) =>
page.locator("form", { has: page.locator('input[name="title"]') });
// Hitta tabellraden för en titel
const getRowByTitle = (page: any, t: string) =>
page.locator("tr", { has: page.locator(".fw-semibold", { hasText: t }) });
test("user can create a todo and see it on dashboard", async ({ page }) => {
await page.goto("/dashboard");
const createForm = getCreateForm(page);
// Fyll i formuläret
await createForm.locator('input[name="title"]').fill(title);
await createForm.locator('textarea[name="description"]').fill(description);
await createForm.locator('select[name="status"]').selectOption("not-started");
// Skicka
await createForm.locator('button[type="submit"]').click();
// Syns i listan
await expect(page.locator(".fw-semibold", { hasText: title })).toBeVisible();
});
test("user can update status to done", async ({ page }) => {
await page.goto("/dashboard");
const row = getRowByTitle(page, title);
await expect(row).toBeVisible();
// Ändra status
const statusSelect = row.locator('select[name="status"]');
await Promise.all([
page.waitForLoadState("networkidle"),
statusSelect.selectOption("done"),
]);
// Kontrollera att status är done
const rowAfter = getRowByTitle(page, title);
await expect(rowAfter.locator('select[name="status"]')).toHaveValue("done");
});
test("user can change status multiple times", async ({ page }) => {
await page.goto("/dashboard");
const row = getRowByTitle(page, title);
await expect(row).toBeVisible();
const statusSelect = row.locator('select[name="status"]');
// not-started -> in-progress
await Promise.all([
page.waitForLoadState("networkidle"),
statusSelect.selectOption("in-progress"),
]);
await expect(getRowByTitle(page, title).locator('select[name="status"]')).toHaveValue(
"in-progress"
);
// in-progress -> done
await Promise.all([
page.waitForLoadState("networkidle"),
statusSelect.selectOption("done"),
]);
await expect(getRowByTitle(page, title).locator('select[name="status"]')).toHaveValue("done");
});
test("cannot create todo without required fields", async ({ page }) => {
await page.goto("/dashboard");
const createForm = getCreateForm(page);
// Tomma fält (bara mellanslag)
await createForm.locator('input[name="title"]').fill(" ");
await createForm.locator('textarea[name="description"]').fill(" ");
await createForm.locator('button[type="submit"]').click();
// Felmeddelande visas
await expect(page.getByText("Title och description krävs.")).toBeVisible();
});
test("cannot create todo when description is missing", async ({ page }) => {
const badTitle = `E2E Missing Desc ${Date.now()}`;
await page.goto("/dashboard");
const createForm = getCreateForm(page);
await createForm.locator('input[name="title"]').fill(badTitle);
await createForm.locator('textarea[name="description"]').fill(" ");
await createForm.locator('button[type="submit"]').click();
// Felmeddelande + ingen rad skapas
await expect(page.getByText("Title och description krävs.")).toBeVisible();
await expect(page.locator(".fw-semibold", { hasText: badTitle })).toHaveCount(0);
});
test("invalid status is normalized to not-started on create", async ({ page }) => {
const t = `E2E Weird Status ${Date.now()}`;
await page.goto("/dashboard");
const createForm = getCreateForm(page);
await createForm.locator('input[name="title"]').fill(t);
await createForm.locator('textarea[name="description"]').fill("desc");
// Sätt ett ogiltigt status-värde
await createForm.locator('select[name="status"]').evaluate((sel: HTMLSelectElement) => {
sel.value = "hax-status";
});
await createForm.locator('button[type="submit"]').click();
// Ska defaulta till not-started
const row = getRowByTitle(page, t);
await expect(row).toBeVisible();
await expect(row.locator('select[name="status"]')).toHaveValue("not-started");
// Städa
await row.locator('button[title="Delete"]').click();
await expect(page.locator(".fw-semibold", { hasText: t })).toHaveCount(0);
});
test("user can delete todo", async ({ page }) => {
await page.goto("/dashboard");
const row = getRowByTitle(page, title);
await expect(row).toBeVisible();
// Ta bort
await row.locator('button[title="Delete"]').click();
// Ska vara borta
await expect(page.locator(".fw-semibold", { hasText: title })).toHaveCount(0);
});
});

View File

@ -0,0 +1,58 @@
{
"info": {
"name": "Todo API",
"_postman_id": "11111111-1111-1111-1111-111111111111",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "GET dashboard",
"request": {
"method": "GET",
"url": "{{baseUrl}}/dashboard"
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('status 200', function () {",
" pm.response.to.have.status(200);",
"});"
]
}
}
]
},
{
"name": "POST create todo",
"request": {
"method": "POST",
"header": [
{ "key": "Content-Type", "value": "application/x-www-form-urlencoded" }
],
"body": {
"mode": "urlencoded",
"urlencoded": [
{ "key": "title", "value": "Newman todo", "type": "text" },
{ "key": "description", "value": "Created by newman", "type": "text" },
{ "key": "status", "value": "not-started", "type": "text" }
]
},
"url": "{{baseUrl}}/todo/create"
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('status is 200 or redirect', function () {",
" pm.expect([200, 302]).to.include(pm.response.code);",
"});"
]
}
}
]
}
]
}

View File

@ -0,0 +1,7 @@
{
"id": "22222222-2222-2222-2222-222222222222",
"name": "local",
"values": [
{ "key": "baseUrl", "value": "http://127.0.0.1:5001", "enabled": true }
]
}

View File

@ -0,0 +1,21 @@
from app.validation import normalize_status, validate_todo_fields
def test_normalize_status_accepts_allowed():
assert normalize_status("done") == "done"
assert normalize_status("in-progress") == "in-progress"
def test_normalize_status_defaults_on_invalid():
assert normalize_status("weird") == "not-started"
assert normalize_status("") == "not-started"
assert normalize_status(None) == "not-started"
def test_validate_todo_fields_ok():
ok, errors = validate_todo_fields("A", "B")
assert ok is True
assert errors == []
def test_validate_todo_fields_missing_both():
ok, errors = validate_todo_fields("", "")
assert ok is False
assert "title_required" in errors
assert "description_required" in errors

BIN
todo.db

Binary file not shown.

3
wsgi.py Normal file
View File

@ -0,0 +1,3 @@
from app import create_app
app = create_app()