diff --git a/README.md b/README.md index 494f1c75..a0c5ad7f 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,62 @@ -# Candidate Assessment: Spec-Driven Development With Codegen Tools - -This assessment evaluates how you use modern code generation tools (for example `5.2-Codex`, `Claude`, `Copilot`, and similar) to design, build, and test a software application using a spec-driven development pattern. You may build a frontend, a backend, or both. - -## Goals -- Build a working application with at least one meaningful feature. -- Create a testing framework to validate the application. -- Demonstrate effective use of code generation tools to accelerate delivery. -- Show clear, maintainable engineering practices. - -## Deliverables -- Application source code in this repository. -- A test suite and test harness that can be run locally. -- Documentation that explains how to run the app and the tests. - -## Scope Options -Pick one: -- Frontend-only application. -- Backend-only application. -- Full-stack application. - -Your solution should include at least one real workflow, for example: -- Create and view a resource. -- Search or filter data. -- Persist data in memory or storage. - -## Rules -- You must use a code generation tool (for example `5.2-Codex`, `Claude`, or similar). You can use multiple tools. -- You must build the application and a testing framework for it. -- The application and tests must run locally. -- Do not include secrets or credentials in this repository. - -## Evaluation Criteria -- Working product: Does the app do what it claims? -- Test coverage: Do tests cover key workflows and edge cases? -- Engineering quality: Clarity, structure, and maintainability. -- Use of codegen: How effectively you used tools to accelerate work. -- Documentation: Clear setup and run instructions. - -## What to Submit -- When you are complete, put up a Pull Request against this repository with your changes. -- A short summary of your approach and tools used in your PR submission -- Any additional information or approach that helped you. +# Banking Transaction System + +A full-stack banking application built for the spec-driven-development assessment. + +## Features +- Create bank accounts with initial balance +- Deposit and withdraw funds +- Transfer between accounts +- View transaction history +- Input validation and error handling + +## Tech Stack +- **Backend:** FastAPI (Python) +- **Database:** SQLite +- **Frontend:** HTML/CSS/JavaScript +- **Testing:** pytest, Playwright + +## Setup + +```bash +# Clone and navigate +git clone https://github.com/Farhod75/spec-driven-development.git +cd spec-driven-development + +# Create virtual environment +python -m venv venv +.\venv\Scripts\activate # Windows +# source venv/bin/activate # macOS/Linux + +# Install dependencies +pip install -r requirements.txt +playwright install chromium + +# Run the Application +uvicorn app.main:app --reload --port 8000 +Open http://localhost:8000 + +# Run Tests +# API tests +pytest tests/test_api.py -v + +# UI tests (server must be running) +pytest tests/test_ui.py -v + +# All tests +pytest -v + +API Endpoints +Method Endpoint Description +POST /accounts Create account +GET /accounts/{id} Get account details +POST /accounts/{id}/deposit Deposit funds +POST /accounts/{id}/withdraw Withdraw funds +POST /transfers Transfer between accounts +GET /accounts/{id}/transactions Get transaction history + +# AI Tools Used + +Claude (Anthropic) - Code generation and debugging + +# Author +Farhod Elbekov \ No newline at end of file diff --git a/SPECS/banking-transactions.md b/SPECS/banking-transactions.md new file mode 100644 index 00000000..d9316675 --- /dev/null +++ b/SPECS/banking-transactions.md @@ -0,0 +1,27 @@ +# Feature Spec: Banking Transactions + +## Goal +Build a simple banking system that allows users to create accounts, manage balances, and perform transactions. + +## Scope +- In: Account creation, deposits, withdrawals, transfers, transaction history, basic UI +- Out: Authentication, multi-currency, external integrations + +## Requirements +- Users can create bank accounts with initial balance +- Users can deposit money into accounts +- Users can withdraw money (with insufficient funds validation) +- Users can transfer between accounts +- Users can view transaction history +- API returns appropriate status codes and error messages + +## Acceptance Criteria +- [ ] POST /accounts creates a new account and returns account details +- [ ] GET /accounts/{id} returns account with current balance +- [ ] POST /accounts/{id}/deposit increases balance +- [ ] POST /accounts/{id}/withdraw decreases balance (fails if insufficient funds) +- [ ] POST /transfers moves money between accounts +- [ ] GET /accounts/{id}/transactions returns transaction history +- [ ] UI allows all operations above +- [ ] All API endpoints have passing tests +- [ ] UI has Playwright E2E tests \ No newline at end of file diff --git a/app/__pycache__/database.cpython-313.pyc b/app/__pycache__/database.cpython-313.pyc new file mode 100644 index 00000000..6c0e99d8 Binary files /dev/null and b/app/__pycache__/database.cpython-313.pyc differ diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 00000000..34fb0944 Binary files /dev/null and b/app/__pycache__/main.cpython-313.pyc differ diff --git a/app/__pycache__/models.cpython-313.pyc b/app/__pycache__/models.cpython-313.pyc new file mode 100644 index 00000000..97ae2464 Binary files /dev/null and b/app/__pycache__/models.cpython-313.pyc differ diff --git a/app/database.py b/app/database.py new file mode 100644 index 00000000..a83bcdd7 --- /dev/null +++ b/app/database.py @@ -0,0 +1,32 @@ +import sqlite3 +from contextlib import contextmanager + +DATABASE = "banking.db" + +def init_db(): + with get_db() as conn: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + balance REAL DEFAULT 0.0 + ); + CREATE TABLE IF NOT EXISTS transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + type TEXT NOT NULL, + amount REAL NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES accounts(id) + ); + """) + +@contextmanager +def get_db(): + conn = sqlite3.connect(DATABASE) + conn.row_factory = sqlite3.Row + try: + yield conn + conn.commit() + finally: + conn.close() \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 00000000..f5daba73 --- /dev/null +++ b/app/main.py @@ -0,0 +1,108 @@ +from fastapi import FastAPI, HTTPException +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from app.models import AccountCreate, Account, DepositWithdraw, Transfer, Transaction +from app.database import init_db, get_db + +app = FastAPI(title="Banking API") + +@app.on_event("startup") +def startup(): + init_db() + +@app.post("/accounts", response_model=Account, status_code=201) +def create_account(data: AccountCreate): + with get_db() as conn: + cursor = conn.execute( + "INSERT INTO accounts (name, balance) VALUES (?, ?)", + (data.name, data.initial_balance) + ) + account_id = cursor.lastrowid + return {"id": account_id, "name": data.name, "balance": data.initial_balance} + +@app.get("/accounts/{account_id}", response_model=Account) +def get_account(account_id: int): + with get_db() as conn: + row = conn.execute("SELECT * FROM accounts WHERE id = ?", (account_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Account not found") + return dict(row) + +@app.post("/accounts/{account_id}/deposit", response_model=Account) +def deposit(account_id: int, data: DepositWithdraw): + if data.amount <= 0: + raise HTTPException(status_code=400, detail="Amount must be positive") + with get_db() as conn: + row = conn.execute("SELECT * FROM accounts WHERE id = ?", (account_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Account not found") + new_balance = row["balance"] + data.amount + conn.execute("UPDATE accounts SET balance = ? WHERE id = ?", (new_balance, account_id)) + conn.execute( + "INSERT INTO transactions (account_id, type, amount) VALUES (?, ?, ?)", + (account_id, "deposit", data.amount) + ) + return {"id": account_id, "name": row["name"], "balance": new_balance} + +@app.post("/accounts/{account_id}/withdraw", response_model=Account) +def withdraw(account_id: int, data: DepositWithdraw): + if data.amount <= 0: + raise HTTPException(status_code=400, detail="Amount must be positive") + with get_db() as conn: + row = conn.execute("SELECT * FROM accounts WHERE id = ?", (account_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Account not found") + if row["balance"] < data.amount: + raise HTTPException(status_code=400, detail="Insufficient funds") + new_balance = row["balance"] - data.amount + conn.execute("UPDATE accounts SET balance = ? WHERE id = ?", (new_balance, account_id)) + conn.execute( + "INSERT INTO transactions (account_id, type, amount) VALUES (?, ?, ?)", + (account_id, "withdraw", data.amount) + ) + return {"id": account_id, "name": row["name"], "balance": new_balance} + +@app.post("/transfers", response_model=dict) +def transfer(data: Transfer): + if data.amount <= 0: + raise HTTPException(status_code=400, detail="Amount must be positive") + with get_db() as conn: + from_acc = conn.execute("SELECT * FROM accounts WHERE id = ?", (data.from_account_id,)).fetchone() + to_acc = conn.execute("SELECT * FROM accounts WHERE id = ?", (data.to_account_id,)).fetchone() + if not from_acc: + raise HTTPException(status_code=404, detail="Source account not found") + if not to_acc: + raise HTTPException(status_code=404, detail="Destination account not found") + if from_acc["balance"] < data.amount: + raise HTTPException(status_code=400, detail="Insufficient funds") + + conn.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", (data.amount, data.from_account_id)) + conn.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", (data.amount, data.to_account_id)) + conn.execute( + "INSERT INTO transactions (account_id, type, amount) VALUES (?, ?, ?)", + (data.from_account_id, "transfer_out", data.amount) + ) + conn.execute( + "INSERT INTO transactions (account_id, type, amount) VALUES (?, ?, ?)", + (data.to_account_id, "transfer_in", data.amount) + ) + return {"message": "Transfer successful"} + +@app.get("/accounts/{account_id}/transactions", response_model=list[Transaction]) +def get_transactions(account_id: int): + with get_db() as conn: + row = conn.execute("SELECT * FROM accounts WHERE id = ?", (account_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Account not found") + rows = conn.execute( + "SELECT * FROM transactions WHERE account_id = ? ORDER BY timestamp DESC", + (account_id,) + ).fetchall() + return [dict(r) for r in rows] + +# Serve frontend +app.mount("/static", StaticFiles(directory="frontend"), name="static") + +@app.get("/") +def serve_frontend(): + return FileResponse("frontend/index.html") \ No newline at end of file diff --git a/app/models.py b/app/models.py new file mode 100644 index 00000000..b83bef58 --- /dev/null +++ b/app/models.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + +class AccountCreate(BaseModel): + name: str + initial_balance: float = 0.0 + +class Account(BaseModel): + id: int + name: str + balance: float + +class DepositWithdraw(BaseModel): + amount: float + +class Transfer(BaseModel): + from_account_id: int + to_account_id: int + amount: float + +class Transaction(BaseModel): + id: int + account_id: int + type: str + amount: float + timestamp: str \ No newline at end of file diff --git a/banking.db b/banking.db new file mode 100644 index 00000000..565f933a Binary files /dev/null and b/banking.db differ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..70f166e8 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,165 @@ + + + + + + Banking App + + + +

🏦 Banking App

+ +
+

Create Account

+ + + +
+
+ +
+

View Account

+ + +
+
+ +
+

Deposit

+ + + +
+
+ +
+

Withdraw

+ + + +
+
+ +
+

Transfer

+ + + + +
+
+ +
+

Transaction History

+ + +
+
+ + + + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..7893c98a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi +uvicorn +pydantic +pytest +httpx +playwright +pytest-playwright \ No newline at end of file diff --git a/tests/__pycache__/test_api.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_api.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 00000000..59dfd0bc Binary files /dev/null and b/tests/__pycache__/test_api.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_ui.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_ui.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 00000000..b3acda67 Binary files /dev/null and b/tests/__pycache__/test_ui.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 00000000..05088ca0 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,149 @@ +import pytest +from fastapi.testclient import TestClient +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.main import app + + +@pytest.fixture(autouse=True) +def clean_db(): + """Reset database before each test""" + from app.database import init_db, DATABASE + if os.path.exists(DATABASE): + os.remove(DATABASE) + init_db() # Initialize the database + yield + if os.path.exists(DATABASE): + os.remove(DATABASE) + +@pytest.fixture +def client(): + return TestClient(app) + +class TestAccountCreation: + def test_create_account_success(self, client): + response = client.post("/accounts", json={"name": "John Doe", "initial_balance": 100.0}) + assert response.status_code == 201 + data = response.json() + assert data["name"] == "John Doe" + assert data["balance"] == 100.0 + assert "id" in data + + def test_create_account_zero_balance(self, client): + response = client.post("/accounts", json={"name": "Jane Doe"}) + assert response.status_code == 201 + assert response.json()["balance"] == 0.0 + +class TestGetAccount: + def test_get_account_success(self, client): + create_res = client.post("/accounts", json={"name": "Test User", "initial_balance": 50.0}) + account_id = create_res.json()["id"] + + response = client.get(f"/accounts/{account_id}") + assert response.status_code == 200 + assert response.json()["name"] == "Test User" + assert response.json()["balance"] == 50.0 + + def test_get_account_not_found(self, client): + response = client.get("/accounts/9999") + assert response.status_code == 404 + assert response.json()["detail"] == "Account not found" + +class TestDeposit: + def test_deposit_success(self, client): + create_res = client.post("/accounts", json={"name": "Depositor", "initial_balance": 100.0}) + account_id = create_res.json()["id"] + + response = client.post(f"/accounts/{account_id}/deposit", json={"amount": 50.0}) + assert response.status_code == 200 + assert response.json()["balance"] == 150.0 + + def test_deposit_invalid_amount(self, client): + create_res = client.post("/accounts", json={"name": "Test", "initial_balance": 100.0}) + account_id = create_res.json()["id"] + + response = client.post(f"/accounts/{account_id}/deposit", json={"amount": -10.0}) + assert response.status_code == 400 + assert response.json()["detail"] == "Amount must be positive" + + def test_deposit_account_not_found(self, client): + response = client.post("/accounts/9999/deposit", json={"amount": 50.0}) + assert response.status_code == 404 + +class TestWithdraw: + def test_withdraw_success(self, client): + create_res = client.post("/accounts", json={"name": "Withdrawer", "initial_balance": 100.0}) + account_id = create_res.json()["id"] + + response = client.post(f"/accounts/{account_id}/withdraw", json={"amount": 30.0}) + assert response.status_code == 200 + assert response.json()["balance"] == 70.0 + + def test_withdraw_insufficient_funds(self, client): + create_res = client.post("/accounts", json={"name": "Broke", "initial_balance": 20.0}) + account_id = create_res.json()["id"] + + response = client.post(f"/accounts/{account_id}/withdraw", json={"amount": 50.0}) + assert response.status_code == 400 + assert response.json()["detail"] == "Insufficient funds" + + def test_withdraw_invalid_amount(self, client): + create_res = client.post("/accounts", json={"name": "Test", "initial_balance": 100.0}) + account_id = create_res.json()["id"] + + response = client.post(f"/accounts/{account_id}/withdraw", json={"amount": 0}) + assert response.status_code == 400 + +class TestTransfer: + def test_transfer_success(self, client): + acc1 = client.post("/accounts", json={"name": "Sender", "initial_balance": 100.0}).json() + acc2 = client.post("/accounts", json={"name": "Receiver", "initial_balance": 50.0}).json() + + response = client.post("/transfers", json={ + "from_account_id": acc1["id"], + "to_account_id": acc2["id"], + "amount": 30.0 + }) + assert response.status_code == 200 + assert response.json()["message"] == "Transfer successful" + + assert client.get(f"/accounts/{acc1['id']}").json()["balance"] == 70.0 + assert client.get(f"/accounts/{acc2['id']}").json()["balance"] == 80.0 + + def test_transfer_insufficient_funds(self, client): + acc1 = client.post("/accounts", json={"name": "Sender", "initial_balance": 10.0}).json() + acc2 = client.post("/accounts", json={"name": "Receiver", "initial_balance": 50.0}).json() + + response = client.post("/transfers", json={ + "from_account_id": acc1["id"], + "to_account_id": acc2["id"], + "amount": 100.0 + }) + assert response.status_code == 400 + assert response.json()["detail"] == "Insufficient funds" + + def test_transfer_account_not_found(self, client): + acc1 = client.post("/accounts", json={"name": "Sender", "initial_balance": 100.0}).json() + + response = client.post("/transfers", json={ + "from_account_id": acc1["id"], + "to_account_id": 9999, + "amount": 30.0 + }) + assert response.status_code == 404 + +class TestTransactions: + def test_get_transactions(self, client): + acc = client.post("/accounts", json={"name": "Active User", "initial_balance": 100.0}).json() + client.post(f"/accounts/{acc['id']}/deposit", json={"amount": 50.0}) + client.post(f"/accounts/{acc['id']}/withdraw", json={"amount": 20.0}) + + response = client.get(f"/accounts/{acc['id']}/transactions") + assert response.status_code == 200 + transactions = response.json() + assert len(transactions) == 2 + types = [t["type"] for t in transactions] + assert "deposit" in types + assert "withdraw" in types \ No newline at end of file diff --git a/tests/test_ui.py b/tests/test_ui.py new file mode 100644 index 00000000..574aa188 --- /dev/null +++ b/tests/test_ui.py @@ -0,0 +1,66 @@ +import pytest +from playwright.sync_api import Page, expect + + + + + +BASE_URL = "http://localhost:8000" + +@pytest.fixture(scope="session") +def browser_context_args(): + return {"viewport": {"width": 1280, "height": 720}} + +class TestBankingUI: + def test_create_account(self, page: Page): + page.goto(BASE_URL) + page.fill("#accountName", "UI Test User") + page.fill("#initialBalance", "500") + page.click("button:has-text('Create')") + expect(page.locator("#createResult")).to_contain_text("Account created") + + def test_view_account(self, page: Page): + page.goto(BASE_URL) + # Create account first + page.fill("#accountName", "View Test") + page.fill("#initialBalance", "200") + page.click("button:has-text('Create')") + page.wait_for_selector("#createResult:has-text('Account created')") + + # View account + page.fill("#viewAccountId", "1") + page.click("button:has-text('View')") + expect(page.locator("#viewResult")).to_contain_text("Balance") + + def test_deposit(self, page: Page): + page.goto(BASE_URL) + # Create account + page.fill("#accountName", "Deposit Test") + page.fill("#initialBalance", "100") + page.click("button:has-text('Create')") + page.wait_for_selector("#createResult:has-text('Account created')") + + # Deposit + page.fill("#depositAccountId", "1") + page.fill("#depositAmount", "50") + page.click("button:has-text('Deposit')") + expect(page.locator("#depositResult")).to_contain_text("Deposited") + + def test_withdraw_insufficient_funds(self, page: Page): + page.goto(BASE_URL) + # Create account with low balance + page.fill("#accountName", "Low Balance") + page.fill("#initialBalance", "10") + page.click("button:has-text('Create')") + page.wait_for_selector("#createResult:has-text('Account created')") + + # Get the created account ID from the result + result_text = page.locator("#createResult").text_content() + # Extract ID from "Account created! ID: X, Name: ..." + account_id = result_text.split("ID: ")[1].split(",")[0] + + # Try to withdraw more than balance + page.fill("#withdrawAccountId", account_id) + page.fill("#withdrawAmount", "100") + page.click("button:has-text('Withdraw')") + expect(page.locator("#withdrawResult")).to_contain_text("Insufficient funds") \ No newline at end of file