Lägg till mer tester/workflow
This commit is contained in:
parent
8491c05ce6
commit
3d1e21ccd9
@ -5,3 +5,6 @@ venv/
|
|||||||
.git/
|
.git/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
todo.db
|
todo.db
|
||||||
|
node_modules/
|
||||||
|
test-results/
|
||||||
|
*.db
|
||||||
45
.github/workflows/tests.yml
vendored
45
.github/workflows/tests.yml
vendored
@ -5,22 +5,51 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pytest:
|
tests:
|
||||||
runs-on: ubuntu-latest
|
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:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install Python dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install -U pip
|
python -m pip install -U pip
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
pip install -r requirements-dev.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: |
|
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
26
.gitignore
vendored
@ -1,4 +1,6 @@
|
|||||||
# Python bytecode
|
# =========================
|
||||||
|
# Python
|
||||||
|
# =========================
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
@ -7,11 +9,29 @@ __pycache__/
|
|||||||
venv/
|
venv/
|
||||||
.env/
|
.env/
|
||||||
|
|
||||||
# Pytest
|
# Pytest / coverage
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# =========================
|
||||||
# SQLite
|
# SQLite
|
||||||
|
# =========================
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
# OS
|
# =========================
|
||||||
|
# Node / Frontend tooling
|
||||||
|
# =========================
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# OS / Editor
|
||||||
|
# =========================
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
|||||||
10
Dockerfile
10
Dockerfile
@ -5,10 +5,12 @@ ENV PYTHONUNBUFFERED=1
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt /app/requirements.txt
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
COPY . /app
|
|
||||||
EXPOSE 5001
|
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"]
|
||||||
|
|||||||
75
README.md
75
README.md
@ -1,7 +1,8 @@
|
|||||||
# TODO – Flask + SQLite
|
# TODO – Flask + SQLite
|
||||||
|
|
||||||
Ett enkelt TODO-system byggt med **Python**, **Flask** och **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`)
|
- Uppdatera status (`not-started`, `in-progress`, `done`)
|
||||||
- Ta bort TODO-poster
|
- Ta bort TODO-poster
|
||||||
- SQLite som databas
|
- SQLite som databas
|
||||||
- Tester med pytest
|
- Tester på flera nivåer (unit, API, E2E)
|
||||||
- CI-redo (GitHub Actions)
|
- CI-redo (GitHub Actions)
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -20,25 +21,41 @@ Projektet är uppsatt med **app factory-pattern**, **pytest-tester** och är red
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
.
|
.
|
||||||
├── app.py # Flask app (app factory)
|
├── .github/
|
||||||
├── db.py # Databasfunktioner (SQLite)
|
│ └── workflows/
|
||||||
├── todo.db # SQLite-databas (lokalt)
|
│ └── tests.yml
|
||||||
├── requirements.txt # Runtime dependencies
|
├── app/
|
||||||
├── requirements-dev.txt # Dev/test dependencies
|
│ ├── __init__.py # Flask app (app factory)
|
||||||
├── templates/
|
│ ├── db.py # Databasfunktioner (SQLite)
|
||||||
│ ├── base.html
|
│ ├── validation.py # Validering
|
||||||
│ └── index.html
|
│ ├── static/
|
||||||
├── static/
|
│ │ └── style.css
|
||||||
│ └── style.css
|
│ ├── templates/
|
||||||
|
│ ├── base.html
|
||||||
|
│ └── index.html
|
||||||
├── tests/
|
├── tests/
|
||||||
│ └── test_app.py
|
│ ├── api/
|
||||||
├── Dockerfile
|
│ │ └── test_app.py
|
||||||
├── pytest.ini
|
│ ├── e2e/
|
||||||
├── requirements.txt
|
│ │ └── todo.spec.ts
|
||||||
├── requirements-dev.txt
|
│ ├── postman/
|
||||||
├── .gitignore
|
│ │ ├── todo.collection.json
|
||||||
|
│ │ └── todo.env.json
|
||||||
|
│ └── unit/
|
||||||
|
│ └── test_validation.py
|
||||||
├── .dockerignore
|
├── .dockerignore
|
||||||
|
├── .gitignore
|
||||||
|
├── Dockerfile
|
||||||
|
├── LICENSE
|
||||||
|
├── package-lock.json
|
||||||
|
├── package.json
|
||||||
|
├── pytest.ini
|
||||||
└── README.md
|
└── README.md
|
||||||
|
├── requirements-dev.txt # Dev/test dependencies
|
||||||
|
├── requirements.txt # Runtime dependencies
|
||||||
|
├── todo.db # SQLite-databas (lokalt)
|
||||||
|
└── wsgi.py
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚀 Kom igång lokalt
|
## 🚀 Kom igång lokalt
|
||||||
@ -63,7 +80,7 @@ pip install -r requirements-dev.txt
|
|||||||
|
|
||||||
### 4. Starta applikationen
|
### 4. Starta applikationen
|
||||||
```bash
|
```bash
|
||||||
python app.py
|
python3 app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Öppna i webbläsaren:
|
### Öppna i webbläsaren:
|
||||||
@ -75,15 +92,25 @@ http://127.0.0.1:5001
|
|||||||
|
|
||||||
## 🧪 Tester
|
## 🧪 Tester
|
||||||
|
|
||||||
Kör alla tester lokalt med pytest:
|
Unit- och integrationstester:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest
|
pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
Med kodtäckning(coverage)
|
API-tester (Postman / Newman):
|
||||||
```bash
|
```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
|
## 🤖 CI – GitHub Actions
|
||||||
@ -104,5 +131,5 @@ Exempel (snabbstart):
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t todo-app .
|
docker build -t todo-app .
|
||||||
docker run -p 5001:5001 todo-app
|
docker run -d -p 5001:5001 todo-app
|
||||||
```
|
```
|
||||||
@ -1,8 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from flask import Flask, render_template, redirect, url_for, request, flash
|
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
|
# Constants
|
||||||
@ -24,7 +25,7 @@ def create_app(test_config: dict | None = None) -> Flask:
|
|||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
# App Configuration
|
# 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(
|
app.config.update(
|
||||||
DB_PATH=default_db,
|
DB_PATH=default_db,
|
||||||
TESTING=False,
|
TESTING=False,
|
||||||
@ -81,15 +82,13 @@ def create_app(test_config: dict | None = None) -> Flask:
|
|||||||
"""
|
"""
|
||||||
title = (request.form.get("title") or "").strip()
|
title = (request.form.get("title") or "").strip()
|
||||||
description = (request.form.get("description") 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")
|
flash("Title och description krävs.", "danger")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("dashboard"))
|
||||||
|
|
||||||
if status not in STATUS_VALUES:
|
|
||||||
status = "not-started"
|
|
||||||
|
|
||||||
now = datetime.now().isoformat(timespec="seconds")
|
now = datetime.now().isoformat(timespec="seconds")
|
||||||
|
|
||||||
with get_db(app) as conn:
|
with get_db(app) as conn:
|
||||||
20
app/validation.py
Normal file
20
app/validation.py
Normal 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
2195
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal 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
9
playwright.config.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -1,2 +1,2 @@
|
|||||||
pytest>=8.0
|
pytest>=8.0
|
||||||
pytest-cov>=5.0
|
pytest-cov>=5.0
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
Flask>=3.0
|
Flask>=3.0
|
||||||
|
gunicorn
|
||||||
@ -25,6 +25,7 @@ def fetch_all(db_path: str, sql: str, params=()):
|
|||||||
# Tests
|
# Tests
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
|
||||||
|
# Test that the dashboard loads successfully
|
||||||
def test_dashboard_loads(tmp_path):
|
def test_dashboard_loads(tmp_path):
|
||||||
db_path = tmp_path / "test.db"
|
db_path = tmp_path / "test.db"
|
||||||
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
|
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
|
||||||
@ -33,7 +34,7 @@ def test_dashboard_loads(tmp_path):
|
|||||||
resp = client.get("/dashboard")
|
resp = client.get("/dashboard")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Test that creating a todo inserts a row in the database
|
||||||
def test_create_todo_inserts_row(tmp_path):
|
def test_create_todo_inserts_row(tmp_path):
|
||||||
db_path = tmp_path / "test.db"
|
db_path = tmp_path / "test.db"
|
||||||
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
|
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")
|
row = fetch_one(str(db_path), "SELECT title, description, status FROM todo")
|
||||||
assert row == ("Test", "Desc", "not-started")
|
assert row == ("Test", "Desc", "not-started")
|
||||||
|
|
||||||
|
# Test that creating a todo requires title and description
|
||||||
def test_create_requires_fields(tmp_path):
|
def test_create_requires_fields(tmp_path):
|
||||||
db_path = tmp_path / "test.db"
|
db_path = tmp_path / "test.db"
|
||||||
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
|
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")
|
row = fetch_one(str(db_path), "SELECT COUNT(*) FROM todo")
|
||||||
assert row[0] == 0
|
assert row[0] == 0
|
||||||
|
|
||||||
|
# Test that updating a todo's status works
|
||||||
def test_invalid_status_is_rejected_on_update(tmp_path):
|
def test_invalid_status_is_rejected_on_update(tmp_path):
|
||||||
db_path = tmp_path / "test.db"
|
db_path = tmp_path / "test.db"
|
||||||
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
|
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]
|
status = fetch_one(str(db_path), "SELECT status FROM todo WHERE id=?", (todo_id,))[0]
|
||||||
assert status == "not-started"
|
assert status == "not-started"
|
||||||
|
|
||||||
|
# Test that updating a todo's status to 'done' works
|
||||||
def test_update_status_to_done(tmp_path):
|
def test_update_status_to_done(tmp_path):
|
||||||
db_path = tmp_path / "test.db"
|
db_path = tmp_path / "test.db"
|
||||||
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
|
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 status == "done"
|
||||||
assert edited is not None
|
assert edited is not None
|
||||||
|
|
||||||
|
# Test that deleting a todo removes it from the database
|
||||||
def test_delete_todo(tmp_path):
|
def test_delete_todo(tmp_path):
|
||||||
db_path = tmp_path / "test.db"
|
db_path = tmp_path / "test.db"
|
||||||
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
|
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")
|
row = fetch_one(str(db_path), "SELECT COUNT(*) FROM todo")
|
||||||
assert row[0] == 0
|
assert row[0] == 0
|
||||||
|
|
||||||
|
# Test that the dashboard lists created todos
|
||||||
def test_dashboard_lists_created_todo(tmp_path):
|
def test_dashboard_lists_created_todo(tmp_path):
|
||||||
db_path = tmp_path / "test.db"
|
db_path = tmp_path / "test.db"
|
||||||
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
|
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"X" in resp.data
|
||||||
assert b"Y" 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):
|
def test_create_invalid_status_defaults_to_not_started(tmp_path):
|
||||||
db_path = tmp_path / "test.db"
|
db_path = tmp_path / "test.db"
|
||||||
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
|
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")
|
row = fetch_one(str(db_path), "SELECT status FROM todo")
|
||||||
assert row[0] == "not-started"
|
assert row[0] == "not-started"
|
||||||
|
|
||||||
|
# Test that deleting a nonexistent todo does not crash
|
||||||
def test_delete_nonexistent_does_not_crash(tmp_path):
|
def test_delete_nonexistent_does_not_crash(tmp_path):
|
||||||
db_path = tmp_path / "test.db"
|
db_path = tmp_path / "test.db"
|
||||||
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
|
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)
|
resp = client.post("/todo/999/delete", follow_redirects=True)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Test that updating a nonexistent todo does not crash
|
||||||
def test_update_nonexistent_does_not_crash(tmp_path):
|
def test_update_nonexistent_does_not_crash(tmp_path):
|
||||||
db_path = tmp_path / "test.db"
|
db_path = tmp_path / "test.db"
|
||||||
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
|
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
|
||||||
144
tests/e2e/todo.spec.ts
Normal file
144
tests/e2e/todo.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
58
tests/postman/todo.collection.json
Normal file
58
tests/postman/todo.collection.json
Normal 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);",
|
||||||
|
"});"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
7
tests/postman/todo.env.json
Normal file
7
tests/postman/todo.env.json
Normal 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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
21
tests/unit/test_validation.py
Normal file
21
tests/unit/test_validation.py
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user