@opslane/claude-code-game 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +59 -0
- package/dist/cli.js.map +1 -0
- package/dist/routes/auth.d.ts +1 -0
- package/dist/routes/auth.js +123 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/levels.d.ts +44 -0
- package/dist/routes/levels.js +78 -0
- package/dist/routes/levels.js.map +1 -0
- package/dist/routes/sessions.d.ts +17 -0
- package/dist/routes/sessions.js +303 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +58 -0
- package/dist/server.js.map +1 -0
- package/dist/terminal.d.ts +6 -0
- package/dist/terminal.js +23 -0
- package/dist/terminal.js.map +1 -0
- package/dist/verification.d.ts +31 -0
- package/dist/verification.js +239 -0
- package/dist/verification.js.map +1 -0
- package/frontend/assets/index-CNVEnbfs.css +1 -0
- package/frontend/assets/index-D70xl9zu.js +27 -0
- package/frontend/index.html +14 -0
- package/frontend/vite.svg +1 -0
- package/keys/v1.pem +9 -0
- package/levels/01-context-is-everything/exercise/README.md +152 -0
- package/levels/01-context-is-everything/exercise/data/expenses.db +0 -0
- package/levels/01-context-is-everything/exercise/database.py +171 -0
- package/levels/01-context-is-everything/exercise/docs/FIRECRAWL_QUICKSTART.md +212 -0
- package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_01.json +2306 -0
- package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_02.json +2394 -0
- package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_03.json +2251 -0
- package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_04.json +1987 -0
- package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_05.json +2229 -0
- package/levels/01-context-is-everything/exercise/main.py +97 -0
- package/levels/01-context-is-everything/exercise/models.py +141 -0
- package/levels/01-context-is-everything/exercise/pyproject.toml +52 -0
- package/levels/01-context-is-everything/exercise/reports.py +138 -0
- package/levels/01-context-is-everything/exercise/seed_data.py +91 -0
- package/levels/01-context-is-everything/exercise/tests/__init__.py +1 -0
- package/levels/01-context-is-everything/exercise/tests/conftest.py +69 -0
- package/levels/01-context-is-everything/exercise/tests/test_database.py +244 -0
- package/levels/01-context-is-everything/exercise/tests/test_models.py +240 -0
- package/levels/01-context-is-everything/exercise/tests/test_reports.py +190 -0
- package/levels/01-context-is-everything/exercise/utils.py +163 -0
- package/levels/01-context-is-everything/lesson.yaml +82 -0
- package/levels/02-claude-md/exercise/README.md +152 -0
- package/levels/02-claude-md/exercise/data/expenses.db +0 -0
- package/levels/02-claude-md/exercise/database.py +171 -0
- package/levels/02-claude-md/exercise/main.py +97 -0
- package/levels/02-claude-md/exercise/models.py +141 -0
- package/levels/02-claude-md/exercise/pyproject.toml +52 -0
- package/levels/02-claude-md/exercise/reports.py +138 -0
- package/levels/02-claude-md/exercise/seed_data.py +91 -0
- package/levels/02-claude-md/exercise/tests/__init__.py +1 -0
- package/levels/02-claude-md/exercise/tests/conftest.py +69 -0
- package/levels/02-claude-md/exercise/tests/test_database.py +244 -0
- package/levels/02-claude-md/exercise/tests/test_models.py +240 -0
- package/levels/02-claude-md/exercise/tests/test_reports.py +190 -0
- package/levels/02-claude-md/exercise/utils.py +163 -0
- package/levels/02-claude-md/lesson.yaml +60 -0
- package/levels/03-read-edit-verify/exercise/CLAUDE.md +15 -0
- package/levels/03-read-edit-verify/exercise/README.md +152 -0
- package/levels/03-read-edit-verify/exercise/data/expenses.db +0 -0
- package/levels/03-read-edit-verify/exercise/database.py +171 -0
- package/levels/03-read-edit-verify/exercise/main.py +97 -0
- package/levels/03-read-edit-verify/exercise/models.py +141 -0
- package/levels/03-read-edit-verify/exercise/pyproject.toml +52 -0
- package/levels/03-read-edit-verify/exercise/reports.py +138 -0
- package/levels/03-read-edit-verify/exercise/seed_data.py +91 -0
- package/levels/03-read-edit-verify/exercise/tests/__init__.py +1 -0
- package/levels/03-read-edit-verify/exercise/tests/conftest.py +69 -0
- package/levels/03-read-edit-verify/exercise/tests/test_database.py +244 -0
- package/levels/03-read-edit-verify/exercise/tests/test_models.py +240 -0
- package/levels/03-read-edit-verify/exercise/tests/test_reports.py +190 -0
- package/levels/03-read-edit-verify/exercise/utils.py +163 -0
- package/levels/03-read-edit-verify/lesson.yaml +60 -0
- package/levels/04-planning-mode/exercise/README.md +152 -0
- package/levels/04-planning-mode/exercise/data/expenses.db +0 -0
- package/levels/04-planning-mode/exercise/database.py +171 -0
- package/levels/04-planning-mode/exercise/main.py +97 -0
- package/levels/04-planning-mode/exercise/models.py +116 -0
- package/levels/04-planning-mode/exercise/pyproject.toml +52 -0
- package/levels/04-planning-mode/exercise/reports.py +138 -0
- package/levels/04-planning-mode/exercise/seed_data.py +91 -0
- package/levels/04-planning-mode/exercise/tests/__init__.py +1 -0
- package/levels/04-planning-mode/exercise/tests/conftest.py +69 -0
- package/levels/04-planning-mode/exercise/tests/test_database.py +244 -0
- package/levels/04-planning-mode/exercise/tests/test_expenses.db +0 -0
- package/levels/04-planning-mode/exercise/tests/test_models.py +240 -0
- package/levels/04-planning-mode/exercise/tests/test_reports.py +190 -0
- package/levels/04-planning-mode/exercise/utils.py +163 -0
- package/levels/04-planning-mode/lesson.yaml +53 -0
- package/levels/05-spec-driven/exercise/README.md +152 -0
- package/levels/05-spec-driven/exercise/data/expenses.db +0 -0
- package/levels/05-spec-driven/exercise/database.py +171 -0
- package/levels/05-spec-driven/exercise/main.py +97 -0
- package/levels/05-spec-driven/exercise/models.py +116 -0
- package/levels/05-spec-driven/exercise/pyproject.toml +52 -0
- package/levels/05-spec-driven/exercise/reports.py +138 -0
- package/levels/05-spec-driven/exercise/seed_data.py +91 -0
- package/levels/05-spec-driven/exercise/tests/__init__.py +1 -0
- package/levels/05-spec-driven/exercise/tests/conftest.py +69 -0
- package/levels/05-spec-driven/exercise/tests/test_database.py +244 -0
- package/levels/05-spec-driven/exercise/tests/test_expenses.db +0 -0
- package/levels/05-spec-driven/exercise/tests/test_models.py +240 -0
- package/levels/05-spec-driven/exercise/tests/test_reports.py +190 -0
- package/levels/05-spec-driven/exercise/utils.py +163 -0
- package/levels/05-spec-driven/lesson.yaml +53 -0
- package/levels/06-sub-agents/exercise/README.md +152 -0
- package/levels/06-sub-agents/exercise/data/expenses.db +0 -0
- package/levels/06-sub-agents/exercise/database.py +171 -0
- package/levels/06-sub-agents/exercise/main.py +97 -0
- package/levels/06-sub-agents/exercise/models.py +116 -0
- package/levels/06-sub-agents/exercise/pyproject.toml +52 -0
- package/levels/06-sub-agents/exercise/reports.py +63 -0
- package/levels/06-sub-agents/exercise/seed_data.py +91 -0
- package/levels/06-sub-agents/exercise/tests/__init__.py +1 -0
- package/levels/06-sub-agents/exercise/tests/conftest.py +69 -0
- package/levels/06-sub-agents/exercise/tests/test_database.py +244 -0
- package/levels/06-sub-agents/exercise/tests/test_models.py +240 -0
- package/levels/06-sub-agents/exercise/tests/test_reports.py +190 -0
- package/levels/06-sub-agents/exercise/utils.py +163 -0
- package/levels/06-sub-agents/lesson.yaml +49 -0
- package/levels/07-skills/exercise/README.md +152 -0
- package/levels/07-skills/exercise/data/expenses.db +0 -0
- package/levels/07-skills/exercise/database.py +171 -0
- package/levels/07-skills/exercise/main.py +97 -0
- package/levels/07-skills/exercise/models.py +116 -0
- package/levels/07-skills/exercise/pyproject.toml +52 -0
- package/levels/07-skills/exercise/reports.py +63 -0
- package/levels/07-skills/exercise/seed_data.py +91 -0
- package/levels/07-skills/exercise/tests/__init__.py +1 -0
- package/levels/07-skills/exercise/tests/conftest.py +69 -0
- package/levels/07-skills/exercise/tests/test_database.py +244 -0
- package/levels/07-skills/exercise/tests/test_models.py +240 -0
- package/levels/07-skills/exercise/tests/test_reports.py +190 -0
- package/levels/07-skills/exercise/utils.py +163 -0
- package/levels/07-skills/lesson.yaml +49 -0
- package/levels/08-mcp-servers/exercise/README.md +152 -0
- package/levels/08-mcp-servers/exercise/data/expenses.db +0 -0
- package/levels/08-mcp-servers/exercise/database.py +171 -0
- package/levels/08-mcp-servers/exercise/main.py +97 -0
- package/levels/08-mcp-servers/exercise/models.py +116 -0
- package/levels/08-mcp-servers/exercise/pyproject.toml +52 -0
- package/levels/08-mcp-servers/exercise/reports.py +63 -0
- package/levels/08-mcp-servers/exercise/seed_data.py +91 -0
- package/levels/08-mcp-servers/exercise/tests/__init__.py +1 -0
- package/levels/08-mcp-servers/exercise/tests/conftest.py +69 -0
- package/levels/08-mcp-servers/exercise/tests/test_database.py +244 -0
- package/levels/08-mcp-servers/exercise/tests/test_models.py +240 -0
- package/levels/08-mcp-servers/exercise/tests/test_reports.py +190 -0
- package/levels/08-mcp-servers/exercise/utils.py +163 -0
- package/levels/08-mcp-servers/lesson.yaml +59 -0
- package/levels/09-plugins/exercise/README.md +152 -0
- package/levels/09-plugins/exercise/data/expenses.db +0 -0
- package/levels/09-plugins/exercise/database.py +171 -0
- package/levels/09-plugins/exercise/main.py +97 -0
- package/levels/09-plugins/exercise/models.py +116 -0
- package/levels/09-plugins/exercise/pyproject.toml +52 -0
- package/levels/09-plugins/exercise/reports.py +63 -0
- package/levels/09-plugins/exercise/seed_data.py +91 -0
- package/levels/09-plugins/exercise/tests/__init__.py +1 -0
- package/levels/09-plugins/exercise/tests/conftest.py +69 -0
- package/levels/09-plugins/exercise/tests/test_database.py +244 -0
- package/levels/09-plugins/exercise/tests/test_models.py +240 -0
- package/levels/09-plugins/exercise/tests/test_reports.py +190 -0
- package/levels/09-plugins/exercise/utils.py +163 -0
- package/levels/09-plugins/lesson.yaml +51 -0
- package/levels/10-hooks/exercise/README.md +152 -0
- package/levels/10-hooks/exercise/data/expenses.db +0 -0
- package/levels/10-hooks/exercise/database.py +171 -0
- package/levels/10-hooks/exercise/main.py +97 -0
- package/levels/10-hooks/exercise/models.py +116 -0
- package/levels/10-hooks/exercise/pyproject.toml +52 -0
- package/levels/10-hooks/exercise/reports.py +63 -0
- package/levels/10-hooks/exercise/seed_data.py +91 -0
- package/levels/10-hooks/exercise/tests/__init__.py +1 -0
- package/levels/10-hooks/exercise/tests/conftest.py +69 -0
- package/levels/10-hooks/exercise/tests/test_database.py +244 -0
- package/levels/10-hooks/exercise/tests/test_models.py +240 -0
- package/levels/10-hooks/exercise/tests/test_reports.py +190 -0
- package/levels/10-hooks/exercise/utils.py +163 -0
- package/levels/10-hooks/lesson.yaml +58 -0
- package/levels/11-worktrees/exercise/README.md +152 -0
- package/levels/11-worktrees/exercise/data/expenses.db +0 -0
- package/levels/11-worktrees/exercise/database.py +171 -0
- package/levels/11-worktrees/exercise/main.py +97 -0
- package/levels/11-worktrees/exercise/models.py +116 -0
- package/levels/11-worktrees/exercise/pyproject.toml +52 -0
- package/levels/11-worktrees/exercise/reports.py +63 -0
- package/levels/11-worktrees/exercise/seed_data.py +91 -0
- package/levels/11-worktrees/exercise/tests/__init__.py +1 -0
- package/levels/11-worktrees/exercise/tests/conftest.py +69 -0
- package/levels/11-worktrees/exercise/tests/test_database.py +244 -0
- package/levels/11-worktrees/exercise/tests/test_models.py +240 -0
- package/levels/11-worktrees/exercise/tests/test_reports.py +190 -0
- package/levels/11-worktrees/exercise/utils.py +163 -0
- package/levels/11-worktrees/lesson.yaml +68 -0
- package/package.json +38 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Seed the database with sample expenses for learning.
|
|
3
|
+
|
|
4
|
+
Run this script to populate the database with example data:
|
|
5
|
+
python seed_data.py
|
|
6
|
+
|
|
7
|
+
This creates a realistic set of expenses for students to work with.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from database import init_db, clear_all_expenses
|
|
12
|
+
from models import add_expense
|
|
13
|
+
|
|
14
|
+
# Sample expenses for the course
|
|
15
|
+
# Note: One entry has lowercase "food" to trigger the case-sensitivity bug
|
|
16
|
+
SAMPLE_EXPENSES = [
|
|
17
|
+
# January 2026 - Primary month for exercises
|
|
18
|
+
(45.50, "Food", "Grocery shopping at Whole Foods", "2026-01-15"),
|
|
19
|
+
(12.00, "Transport", "Uber to airport", "2026-01-14"),
|
|
20
|
+
(89.99, "Shopping", "Running shoes from Nike", "2026-01-10"),
|
|
21
|
+
(150.00, "Bills", "Electric bill", "2026-01-01"),
|
|
22
|
+
(8.50, "food", "Coffee at Starbucks", "2026-01-20"), # lowercase - triggers bug!
|
|
23
|
+
(35.00, "Entertainment", "Movie tickets for two", "2026-01-18"),
|
|
24
|
+
(22.00, "Food", "Lunch with team", "2026-01-19"),
|
|
25
|
+
(65.00, "Health", "Gym membership monthly", "2026-01-01"),
|
|
26
|
+
(15.75, "Food", "Thai takeout dinner", "2026-01-17"),
|
|
27
|
+
(42.00, "Transport", "Weekly metro pass", "2026-01-13"),
|
|
28
|
+
(28.99, "Entertainment", "Netflix + Spotify", "2026-01-05"),
|
|
29
|
+
(120.00, "Shopping", "Winter jacket on sale", "2026-01-08"),
|
|
30
|
+
|
|
31
|
+
# December 2025 - For month filtering tests
|
|
32
|
+
(200.00, "Shopping", "Holiday gifts for family", "2025-12-20"),
|
|
33
|
+
(55.00, "Food", "Holiday dinner groceries", "2025-12-25"),
|
|
34
|
+
(75.00, "Entertainment", "Concert tickets", "2025-12-15"),
|
|
35
|
+
(30.00, "Transport", "Airport parking", "2025-12-23"),
|
|
36
|
+
|
|
37
|
+
# November 2025 - Additional historical data
|
|
38
|
+
(95.00, "Bills", "Internet bill", "2025-11-15"),
|
|
39
|
+
(180.00, "Health", "Doctor visit copay", "2025-11-10"),
|
|
40
|
+
(45.00, "Food", "Birthday dinner", "2025-11-22"),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def seed_database(verbose: bool = True) -> int:
|
|
45
|
+
"""Initialize and seed the database with sample data.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
verbose: Whether to print progress messages
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Number of expenses added
|
|
52
|
+
"""
|
|
53
|
+
if verbose:
|
|
54
|
+
print("Initializing database...")
|
|
55
|
+
init_db()
|
|
56
|
+
|
|
57
|
+
if verbose:
|
|
58
|
+
print("Clearing existing data...")
|
|
59
|
+
clear_all_expenses()
|
|
60
|
+
|
|
61
|
+
if verbose:
|
|
62
|
+
print("Adding sample expenses...")
|
|
63
|
+
|
|
64
|
+
count = 0
|
|
65
|
+
for amount, category, desc, date_str in SAMPLE_EXPENSES:
|
|
66
|
+
date = datetime.strptime(date_str, "%Y-%m-%d")
|
|
67
|
+
expense = add_expense(amount, category, desc, date)
|
|
68
|
+
count += 1
|
|
69
|
+
if verbose:
|
|
70
|
+
print(f" Added: {expense.id} - ${amount:.2f} - {desc[:40]}")
|
|
71
|
+
|
|
72
|
+
if verbose:
|
|
73
|
+
print(f"\n{'='*50}")
|
|
74
|
+
print(f"Seeded {count} expenses successfully!")
|
|
75
|
+
print(f"{'='*50}")
|
|
76
|
+
print("\nTry these commands:")
|
|
77
|
+
print(" python main.py list")
|
|
78
|
+
print(" python main.py list --category Food")
|
|
79
|
+
print(" python main.py summary")
|
|
80
|
+
print(" pytest -v")
|
|
81
|
+
|
|
82
|
+
return count
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main():
|
|
86
|
+
"""Entry point for seeding script."""
|
|
87
|
+
seed_database()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Test package for expense tracker."""
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Pytest configuration and shared fixtures."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import pytest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# Add parent directory to path so we can import our modules
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
10
|
+
|
|
11
|
+
# Use a separate test database
|
|
12
|
+
TEST_DB_PATH = Path(__file__).parent / "test_expenses.db"
|
|
13
|
+
os.environ["EXPENSE_DB_PATH"] = str(TEST_DB_PATH)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture(autouse=True)
|
|
17
|
+
def clean_test_db():
|
|
18
|
+
"""Ensure clean database for each test.
|
|
19
|
+
|
|
20
|
+
This fixture runs automatically before and after each test.
|
|
21
|
+
It initializes the database and cleans up after.
|
|
22
|
+
"""
|
|
23
|
+
from database import init_db, clear_all_expenses
|
|
24
|
+
|
|
25
|
+
# Setup: initialize fresh database
|
|
26
|
+
init_db()
|
|
27
|
+
clear_all_expenses()
|
|
28
|
+
|
|
29
|
+
yield # Run the test
|
|
30
|
+
|
|
31
|
+
# Teardown: clean up
|
|
32
|
+
clear_all_expenses()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture
|
|
36
|
+
def sample_expenses():
|
|
37
|
+
"""Create a set of sample expenses for testing.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
List of created Expense objects
|
|
41
|
+
"""
|
|
42
|
+
from datetime import datetime
|
|
43
|
+
from models import add_expense
|
|
44
|
+
|
|
45
|
+
expenses = [
|
|
46
|
+
add_expense(50.00, "Food", "Groceries", datetime(2026, 1, 15)),
|
|
47
|
+
add_expense(30.00, "Transport", "Uber ride", datetime(2026, 1, 14)),
|
|
48
|
+
add_expense(25.00, "Food", "Lunch", datetime(2026, 1, 16)),
|
|
49
|
+
add_expense(100.00, "Shopping", "Clothes", datetime(2026, 1, 10)),
|
|
50
|
+
add_expense(15.00, "Entertainment", "Movie", datetime(2026, 1, 12)),
|
|
51
|
+
]
|
|
52
|
+
return expenses
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.fixture
|
|
56
|
+
def mixed_case_expenses():
|
|
57
|
+
"""Create expenses with mixed case categories for bug testing.
|
|
58
|
+
|
|
59
|
+
This fixture specifically tests the case-sensitivity bug.
|
|
60
|
+
"""
|
|
61
|
+
from datetime import datetime
|
|
62
|
+
from models import add_expense
|
|
63
|
+
|
|
64
|
+
expenses = [
|
|
65
|
+
add_expense(50.00, "Food", "Groceries", datetime(2026, 1, 15)),
|
|
66
|
+
add_expense(25.00, "food", "Coffee", datetime(2026, 1, 16)), # lowercase
|
|
67
|
+
add_expense(30.00, "FOOD", "Dinner", datetime(2026, 1, 17)), # uppercase
|
|
68
|
+
]
|
|
69
|
+
return expenses
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Tests for database operations."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from database import (
|
|
6
|
+
init_db,
|
|
7
|
+
save_expense,
|
|
8
|
+
load_expenses,
|
|
9
|
+
get_expense_by_id,
|
|
10
|
+
remove_expense,
|
|
11
|
+
clear_all_expenses,
|
|
12
|
+
get_expense_count
|
|
13
|
+
)
|
|
14
|
+
from models import Expense
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestDatabaseInit:
|
|
18
|
+
"""Tests for database initialization."""
|
|
19
|
+
|
|
20
|
+
def test_init_creates_table(self):
|
|
21
|
+
"""Test that init_db creates the expenses table."""
|
|
22
|
+
init_db()
|
|
23
|
+
# If we get here without error, table was created
|
|
24
|
+
expenses = load_expenses()
|
|
25
|
+
assert isinstance(expenses, list)
|
|
26
|
+
|
|
27
|
+
def test_init_idempotent(self):
|
|
28
|
+
"""Test that init_db can be called multiple times safely."""
|
|
29
|
+
init_db()
|
|
30
|
+
init_db()
|
|
31
|
+
init_db()
|
|
32
|
+
# Should not raise any errors
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestSaveAndLoad:
|
|
36
|
+
"""Tests for saving and loading expenses."""
|
|
37
|
+
|
|
38
|
+
def test_save_and_load_single(self):
|
|
39
|
+
"""Test saving and loading a single expense."""
|
|
40
|
+
expense = Expense(
|
|
41
|
+
id="test123",
|
|
42
|
+
amount=50.0,
|
|
43
|
+
category="Food",
|
|
44
|
+
description="Test expense",
|
|
45
|
+
date=datetime(2026, 1, 15, 12, 0)
|
|
46
|
+
)
|
|
47
|
+
save_expense(expense)
|
|
48
|
+
|
|
49
|
+
expenses = load_expenses()
|
|
50
|
+
assert len(expenses) == 1
|
|
51
|
+
assert expenses[0].id == "test123"
|
|
52
|
+
assert expenses[0].amount == 50.0
|
|
53
|
+
assert expenses[0].category == "Food"
|
|
54
|
+
|
|
55
|
+
def test_save_multiple(self):
|
|
56
|
+
"""Test saving multiple expenses."""
|
|
57
|
+
for i in range(5):
|
|
58
|
+
expense = Expense(
|
|
59
|
+
id=f"test{i}",
|
|
60
|
+
amount=float(i * 10),
|
|
61
|
+
category="Test",
|
|
62
|
+
description=f"Expense {i}",
|
|
63
|
+
date=datetime.now()
|
|
64
|
+
)
|
|
65
|
+
save_expense(expense)
|
|
66
|
+
|
|
67
|
+
expenses = load_expenses()
|
|
68
|
+
assert len(expenses) == 5
|
|
69
|
+
|
|
70
|
+
def test_load_preserves_data(self):
|
|
71
|
+
"""Test that all expense fields are preserved on load."""
|
|
72
|
+
original = Expense(
|
|
73
|
+
id="preserve123",
|
|
74
|
+
amount=99.99,
|
|
75
|
+
category="Shopping",
|
|
76
|
+
description="Test all fields",
|
|
77
|
+
date=datetime(2026, 6, 15, 14, 30),
|
|
78
|
+
recurring=True,
|
|
79
|
+
recurring_frequency="monthly"
|
|
80
|
+
)
|
|
81
|
+
save_expense(original)
|
|
82
|
+
|
|
83
|
+
loaded = load_expenses()[0]
|
|
84
|
+
assert loaded.id == original.id
|
|
85
|
+
assert loaded.amount == original.amount
|
|
86
|
+
assert loaded.category == original.category
|
|
87
|
+
assert loaded.description == original.description
|
|
88
|
+
assert loaded.date == original.date
|
|
89
|
+
assert loaded.recurring == original.recurring
|
|
90
|
+
assert loaded.recurring_frequency == original.recurring_frequency
|
|
91
|
+
|
|
92
|
+
def test_load_ordered_by_date(self):
|
|
93
|
+
"""Test that load_expenses returns results ordered by date descending."""
|
|
94
|
+
dates = [
|
|
95
|
+
datetime(2026, 1, 1),
|
|
96
|
+
datetime(2026, 1, 15),
|
|
97
|
+
datetime(2026, 1, 10),
|
|
98
|
+
]
|
|
99
|
+
for i, date in enumerate(dates):
|
|
100
|
+
expense = Expense(
|
|
101
|
+
id=f"date{i}",
|
|
102
|
+
amount=10.0,
|
|
103
|
+
category="Test",
|
|
104
|
+
description=f"Expense {i}",
|
|
105
|
+
date=date
|
|
106
|
+
)
|
|
107
|
+
save_expense(expense)
|
|
108
|
+
|
|
109
|
+
expenses = load_expenses()
|
|
110
|
+
loaded_dates = [e.date for e in expenses]
|
|
111
|
+
assert loaded_dates == sorted(loaded_dates, reverse=True)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TestGetExpenseById:
|
|
115
|
+
"""Tests for getting expense by ID."""
|
|
116
|
+
|
|
117
|
+
def test_get_existing(self):
|
|
118
|
+
"""Test retrieving an existing expense by ID."""
|
|
119
|
+
expense = Expense(
|
|
120
|
+
id="findme123",
|
|
121
|
+
amount=25.0,
|
|
122
|
+
category="Transport",
|
|
123
|
+
description="Bus fare",
|
|
124
|
+
date=datetime.now()
|
|
125
|
+
)
|
|
126
|
+
save_expense(expense)
|
|
127
|
+
|
|
128
|
+
result = get_expense_by_id("findme123")
|
|
129
|
+
assert result is not None
|
|
130
|
+
assert result.id == "findme123"
|
|
131
|
+
assert result.description == "Bus fare"
|
|
132
|
+
|
|
133
|
+
def test_get_nonexistent(self):
|
|
134
|
+
"""Test that non-existent ID returns None."""
|
|
135
|
+
result = get_expense_by_id("does-not-exist")
|
|
136
|
+
assert result is None
|
|
137
|
+
|
|
138
|
+
def test_get_after_multiple_saves(self):
|
|
139
|
+
"""Test getting specific expense after saving multiple."""
|
|
140
|
+
for i in range(10):
|
|
141
|
+
expense = Expense(
|
|
142
|
+
id=f"multi{i}",
|
|
143
|
+
amount=float(i),
|
|
144
|
+
category="Test",
|
|
145
|
+
description=f"Expense {i}",
|
|
146
|
+
date=datetime.now()
|
|
147
|
+
)
|
|
148
|
+
save_expense(expense)
|
|
149
|
+
|
|
150
|
+
result = get_expense_by_id("multi5")
|
|
151
|
+
assert result is not None
|
|
152
|
+
assert result.amount == 5.0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class TestRemoveExpense:
|
|
156
|
+
"""Tests for removing expenses."""
|
|
157
|
+
|
|
158
|
+
def test_remove_existing(self):
|
|
159
|
+
"""Test removing an existing expense."""
|
|
160
|
+
expense = Expense(
|
|
161
|
+
id="removeme",
|
|
162
|
+
amount=10.0,
|
|
163
|
+
category="Test",
|
|
164
|
+
description="To be removed",
|
|
165
|
+
date=datetime.now()
|
|
166
|
+
)
|
|
167
|
+
save_expense(expense)
|
|
168
|
+
assert get_expense_by_id("removeme") is not None
|
|
169
|
+
|
|
170
|
+
result = remove_expense("removeme")
|
|
171
|
+
assert result is True
|
|
172
|
+
assert get_expense_by_id("removeme") is None
|
|
173
|
+
|
|
174
|
+
def test_remove_nonexistent(self):
|
|
175
|
+
"""Test removing non-existent expense returns False."""
|
|
176
|
+
result = remove_expense("fake-id")
|
|
177
|
+
assert result is False
|
|
178
|
+
|
|
179
|
+
def test_remove_only_target(self):
|
|
180
|
+
"""Test that remove only deletes the target expense."""
|
|
181
|
+
for i in range(3):
|
|
182
|
+
expense = Expense(
|
|
183
|
+
id=f"keep{i}",
|
|
184
|
+
amount=float(i),
|
|
185
|
+
category="Test",
|
|
186
|
+
description=f"Expense {i}",
|
|
187
|
+
date=datetime.now()
|
|
188
|
+
)
|
|
189
|
+
save_expense(expense)
|
|
190
|
+
|
|
191
|
+
remove_expense("keep1")
|
|
192
|
+
|
|
193
|
+
expenses = load_expenses()
|
|
194
|
+
ids = [e.id for e in expenses]
|
|
195
|
+
assert "keep0" in ids
|
|
196
|
+
assert "keep1" not in ids
|
|
197
|
+
assert "keep2" in ids
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class TestClearAll:
|
|
201
|
+
"""Tests for clearing all expenses."""
|
|
202
|
+
|
|
203
|
+
def test_clear_removes_all(self):
|
|
204
|
+
"""Test that clear_all_expenses removes everything."""
|
|
205
|
+
for i in range(5):
|
|
206
|
+
expense = Expense(
|
|
207
|
+
id=f"clear{i}",
|
|
208
|
+
amount=float(i),
|
|
209
|
+
category="Test",
|
|
210
|
+
description=f"Expense {i}",
|
|
211
|
+
date=datetime.now()
|
|
212
|
+
)
|
|
213
|
+
save_expense(expense)
|
|
214
|
+
|
|
215
|
+
assert len(load_expenses()) == 5
|
|
216
|
+
|
|
217
|
+
clear_all_expenses()
|
|
218
|
+
assert len(load_expenses()) == 0
|
|
219
|
+
|
|
220
|
+
def test_clear_empty_db(self):
|
|
221
|
+
"""Test that clearing empty database doesn't error."""
|
|
222
|
+
clear_all_expenses() # Should not raise
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class TestExpenseCount:
|
|
226
|
+
"""Tests for getting expense count."""
|
|
227
|
+
|
|
228
|
+
def test_count_empty(self):
|
|
229
|
+
"""Test count on empty database."""
|
|
230
|
+
assert get_expense_count() == 0
|
|
231
|
+
|
|
232
|
+
def test_count_with_expenses(self):
|
|
233
|
+
"""Test count with expenses."""
|
|
234
|
+
for i in range(7):
|
|
235
|
+
expense = Expense(
|
|
236
|
+
id=f"count{i}",
|
|
237
|
+
amount=float(i),
|
|
238
|
+
category="Test",
|
|
239
|
+
description=f"Expense {i}",
|
|
240
|
+
date=datetime.now()
|
|
241
|
+
)
|
|
242
|
+
save_expense(expense)
|
|
243
|
+
|
|
244
|
+
assert get_expense_count() == 7
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Tests for expense models.
|
|
2
|
+
|
|
3
|
+
This test file includes intentionally failing tests that students fix
|
|
4
|
+
during the course:
|
|
5
|
+
|
|
6
|
+
- test_add_negative_amount_should_fail: Fails until Lesson 3 (validation bug)
|
|
7
|
+
- test_list_by_category_case_insensitive: Fails until a later lesson (case bug)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from models import (
|
|
13
|
+
add_expense,
|
|
14
|
+
list_expenses,
|
|
15
|
+
delete_expense,
|
|
16
|
+
get_expense,
|
|
17
|
+
get_total_by_category,
|
|
18
|
+
Expense
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestAddExpense:
|
|
23
|
+
"""Tests for the add_expense function."""
|
|
24
|
+
|
|
25
|
+
def test_add_basic_expense(self):
|
|
26
|
+
"""Test adding a simple expense."""
|
|
27
|
+
expense = add_expense(50.0, "Food", "Lunch at cafe")
|
|
28
|
+
|
|
29
|
+
assert expense.amount == 50.0
|
|
30
|
+
assert expense.category == "Food"
|
|
31
|
+
assert expense.description == "Lunch at cafe"
|
|
32
|
+
assert expense.id is not None
|
|
33
|
+
assert len(expense.id) == 8 # UUID prefix
|
|
34
|
+
|
|
35
|
+
def test_add_expense_with_date(self):
|
|
36
|
+
"""Test adding expense with specific date."""
|
|
37
|
+
date = datetime(2026, 1, 15, 12, 30)
|
|
38
|
+
expense = add_expense(25.0, "Transport", "Uber to meeting", date)
|
|
39
|
+
|
|
40
|
+
assert expense.date == date
|
|
41
|
+
assert expense.amount == 25.0
|
|
42
|
+
|
|
43
|
+
def test_add_expense_default_date(self):
|
|
44
|
+
"""Test that expense gets current date if not specified."""
|
|
45
|
+
before = datetime.now()
|
|
46
|
+
expense = add_expense(10.0, "Food", "Snack")
|
|
47
|
+
after = datetime.now()
|
|
48
|
+
|
|
49
|
+
assert before <= expense.date <= after
|
|
50
|
+
|
|
51
|
+
def test_add_expense_persists(self):
|
|
52
|
+
"""Test that added expense can be retrieved."""
|
|
53
|
+
expense = add_expense(75.0, "Shopping", "New book")
|
|
54
|
+
|
|
55
|
+
retrieved = get_expense(expense.id)
|
|
56
|
+
assert retrieved is not None
|
|
57
|
+
assert retrieved.amount == 75.0
|
|
58
|
+
assert retrieved.description == "New book"
|
|
59
|
+
|
|
60
|
+
# =========================================================
|
|
61
|
+
# BUG TESTS - These fail until students fix them in Lesson 3
|
|
62
|
+
# =========================================================
|
|
63
|
+
|
|
64
|
+
def test_add_negative_amount_should_fail(self):
|
|
65
|
+
"""BUG TEST: Negative amounts should raise ValueError.
|
|
66
|
+
|
|
67
|
+
This test FAILS initially - students fix this in Lesson 3
|
|
68
|
+
by adding validation to add_expense().
|
|
69
|
+
|
|
70
|
+
Expected fix:
|
|
71
|
+
if amount <= 0:
|
|
72
|
+
raise ValueError("Amount must be greater than 0")
|
|
73
|
+
"""
|
|
74
|
+
with pytest.raises(ValueError, match="[Aa]mount"):
|
|
75
|
+
add_expense(-50.0, "Food", "Invalid expense")
|
|
76
|
+
|
|
77
|
+
def test_add_zero_amount_should_fail(self):
|
|
78
|
+
"""BUG TEST: Zero amount should raise ValueError.
|
|
79
|
+
|
|
80
|
+
This test FAILS initially - students fix this in Lesson 3.
|
|
81
|
+
"""
|
|
82
|
+
with pytest.raises(ValueError, match="[Aa]mount"):
|
|
83
|
+
add_expense(0, "Food", "Free lunch doesn't exist")
|
|
84
|
+
|
|
85
|
+
def test_add_empty_category_should_fail(self):
|
|
86
|
+
"""BUG TEST: Empty category should raise ValueError.
|
|
87
|
+
|
|
88
|
+
This test FAILS initially - students fix this in Lesson 3.
|
|
89
|
+
"""
|
|
90
|
+
with pytest.raises(ValueError, match="[Cc]ategory"):
|
|
91
|
+
add_expense(50.0, "", "No category")
|
|
92
|
+
|
|
93
|
+
def test_add_whitespace_category_should_fail(self):
|
|
94
|
+
"""BUG TEST: Whitespace-only category should raise ValueError."""
|
|
95
|
+
with pytest.raises(ValueError, match="[Cc]ategory"):
|
|
96
|
+
add_expense(50.0, " ", "Whitespace category")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TestListExpenses:
|
|
100
|
+
"""Tests for the list_expenses function."""
|
|
101
|
+
|
|
102
|
+
def test_list_all_expenses(self, sample_expenses):
|
|
103
|
+
"""Test listing all expenses without filters."""
|
|
104
|
+
expenses = list_expenses()
|
|
105
|
+
assert len(expenses) == 5
|
|
106
|
+
|
|
107
|
+
def test_list_empty(self):
|
|
108
|
+
"""Test listing when no expenses exist."""
|
|
109
|
+
expenses = list_expenses()
|
|
110
|
+
assert len(expenses) == 0
|
|
111
|
+
|
|
112
|
+
def test_list_by_category(self):
|
|
113
|
+
"""Test filtering by category (exact match)."""
|
|
114
|
+
add_expense(50.0, "Food", "Lunch")
|
|
115
|
+
add_expense(30.0, "Transport", "Uber")
|
|
116
|
+
add_expense(25.0, "Food", "Coffee")
|
|
117
|
+
|
|
118
|
+
food = list_expenses(category="Food")
|
|
119
|
+
assert len(food) == 2
|
|
120
|
+
assert all(e.category == "Food" for e in food)
|
|
121
|
+
|
|
122
|
+
def test_list_by_month(self):
|
|
123
|
+
"""Test filtering by month."""
|
|
124
|
+
add_expense(50.0, "Food", "Jan expense", datetime(2026, 1, 15))
|
|
125
|
+
add_expense(30.0, "Food", "Feb expense", datetime(2026, 2, 10))
|
|
126
|
+
|
|
127
|
+
jan = list_expenses(month="2026-01")
|
|
128
|
+
assert len(jan) == 1
|
|
129
|
+
assert jan[0].description == "Jan expense"
|
|
130
|
+
|
|
131
|
+
def test_list_with_limit(self):
|
|
132
|
+
"""Test limiting number of results."""
|
|
133
|
+
for i in range(10):
|
|
134
|
+
add_expense(float(i + 1), "Food", f"Expense {i}")
|
|
135
|
+
|
|
136
|
+
limited = list_expenses(limit=5)
|
|
137
|
+
assert len(limited) == 5
|
|
138
|
+
|
|
139
|
+
def test_list_sorted_by_date(self):
|
|
140
|
+
"""Test that results are sorted by date descending."""
|
|
141
|
+
add_expense(10.0, "Food", "Old", datetime(2026, 1, 1))
|
|
142
|
+
add_expense(20.0, "Food", "New", datetime(2026, 1, 15))
|
|
143
|
+
add_expense(30.0, "Food", "Middle", datetime(2026, 1, 10))
|
|
144
|
+
|
|
145
|
+
expenses = list_expenses()
|
|
146
|
+
dates = [e.date for e in expenses]
|
|
147
|
+
assert dates == sorted(dates, reverse=True)
|
|
148
|
+
|
|
149
|
+
# =========================================================
|
|
150
|
+
# BUG TESTS - These fail until students fix them in a later lesson
|
|
151
|
+
# =========================================================
|
|
152
|
+
|
|
153
|
+
def test_list_by_category_case_insensitive(self, mixed_case_expenses):
|
|
154
|
+
"""BUG TEST: Category filter should be case-insensitive.
|
|
155
|
+
|
|
156
|
+
This test FAILS initially - students fix this in a later lesson.
|
|
157
|
+
|
|
158
|
+
The seed data includes lowercase "food" entries that won't
|
|
159
|
+
match "Food" until the bug is fixed.
|
|
160
|
+
|
|
161
|
+
Expected fix in list_expenses():
|
|
162
|
+
if category:
|
|
163
|
+
category_lower = category.lower()
|
|
164
|
+
expenses = [e for e in expenses if e.category.lower() == category_lower]
|
|
165
|
+
"""
|
|
166
|
+
# mixed_case_expenses has: "Food", "food", "FOOD"
|
|
167
|
+
food = list_expenses(category="Food")
|
|
168
|
+
assert len(food) == 3, (
|
|
169
|
+
f"Expected 3 'Food' expenses (case-insensitive), got {len(food)}. "
|
|
170
|
+
"Fix: make category filtering case-insensitive."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def test_list_category_lowercase_query(self, mixed_case_expenses):
|
|
174
|
+
"""BUG TEST: Lowercase category query should still find matches."""
|
|
175
|
+
food = list_expenses(category="food") # lowercase query
|
|
176
|
+
assert len(food) == 3, "Lowercase 'food' should match all Food variants"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class TestDeleteExpense:
|
|
180
|
+
"""Tests for the delete_expense function."""
|
|
181
|
+
|
|
182
|
+
def test_delete_existing(self):
|
|
183
|
+
"""Test deleting an existing expense."""
|
|
184
|
+
expense = add_expense(50.0, "Food", "To be deleted")
|
|
185
|
+
|
|
186
|
+
result = delete_expense(expense.id)
|
|
187
|
+
assert result is True
|
|
188
|
+
|
|
189
|
+
# Verify it's gone
|
|
190
|
+
assert get_expense(expense.id) is None
|
|
191
|
+
|
|
192
|
+
def test_delete_nonexistent(self):
|
|
193
|
+
"""Test deleting non-existent expense returns False."""
|
|
194
|
+
result = delete_expense("fake-id-12345")
|
|
195
|
+
assert result is False
|
|
196
|
+
|
|
197
|
+
def test_delete_twice(self):
|
|
198
|
+
"""Test that deleting same expense twice fails second time."""
|
|
199
|
+
expense = add_expense(50.0, "Food", "Delete me")
|
|
200
|
+
|
|
201
|
+
assert delete_expense(expense.id) is True
|
|
202
|
+
assert delete_expense(expense.id) is False
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class TestGetExpense:
|
|
206
|
+
"""Tests for the get_expense function."""
|
|
207
|
+
|
|
208
|
+
def test_get_existing(self):
|
|
209
|
+
"""Test getting an existing expense."""
|
|
210
|
+
created = add_expense(99.99, "Shopping", "Test item")
|
|
211
|
+
|
|
212
|
+
retrieved = get_expense(created.id)
|
|
213
|
+
assert retrieved is not None
|
|
214
|
+
assert retrieved.id == created.id
|
|
215
|
+
assert retrieved.amount == 99.99
|
|
216
|
+
|
|
217
|
+
def test_get_nonexistent(self):
|
|
218
|
+
"""Test getting non-existent expense returns None."""
|
|
219
|
+
result = get_expense("nonexistent-id")
|
|
220
|
+
assert result is None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class TestGetTotalByCategory:
|
|
224
|
+
"""Tests for the get_total_by_category function."""
|
|
225
|
+
|
|
226
|
+
def test_total_single_category(self):
|
|
227
|
+
"""Test total for a single category."""
|
|
228
|
+
add_expense(50.0, "Food", "Item 1")
|
|
229
|
+
add_expense(30.0, "Food", "Item 2")
|
|
230
|
+
add_expense(100.0, "Shopping", "Other category")
|
|
231
|
+
|
|
232
|
+
total = get_total_by_category("Food")
|
|
233
|
+
assert total == 80.0
|
|
234
|
+
|
|
235
|
+
def test_total_empty_category(self):
|
|
236
|
+
"""Test total for category with no expenses."""
|
|
237
|
+
add_expense(50.0, "Food", "Food item")
|
|
238
|
+
|
|
239
|
+
total = get_total_by_category("Transport")
|
|
240
|
+
assert total == 0.0
|