diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..bfd5333 Binary files /dev/null and b/.coverage differ diff --git a/README.md b/README.md index a3d6a25..51ec7be 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,145 @@ -# Simple Inventory +# Simple Inventory — REST API Extension -This is a simple tool for managing a product inventory: record purchased inventory, manage suppliers, view available stock, record sales, view analytics. It uses a bunch of a off-the-shelf software; the result is something that: +> **Original project:** [gassc/simple-inventory](https://github.com/gassc/simple-inventory) +> **This fork:** adds a full REST API for the `Product` resource. -* is better than using a spreadsheet; -* is a lot less confusing than building/maintaining an something like an MS Access database; -* provides consistency and promise. +--- -I built this for a relative's small medical practice, which needed to replace an old _Microsoft Works_ database. The functionality here is decidedly built-to-purpose, supporting existing, largely paper-based workflows. +## What Changed -## Software +A new branch **`feature/rest-api-products`** adds RESTful CRUD endpoints for +products, sitting alongside the existing Flask-Admin UI without breaking it. -This is built with Python 3, Python-Flask, and Flask-Admin. The database is SQLite. It uses Twitter Bootstrap v3 for the GUI, and Chart.js for the charts in the analytics view. +### New files -## Development Quickstart +| Path | Purpose | +|---|---| +| `api/__init__.py` | Makes `api/` a Python package | +| `api/products.py` | Products Blueprint – all five CRUD routes | +| `tests/test_products_api.py` | 25 unit tests (100 % coverage) | -To develop this project: +### Modified files -1. Clone the repository: +| Path | Change | +|---|---| +| `project/__init__.py` | Registers the `products_bp` Blueprint | +| `requirements.txt` | Added `pytest`, `pytest-cov` | - ```python - git clone https://github.com/gassc/simple-inventory.git - cd simple-inventory - ``` +--- -2. Create and activate a virtual environment: +## API Reference - `python -m venv ENV` +Base URL: `http://localhost:5000/api` - *bash* - - ```sh - source env/bin/activate - ``` +### Products - - *windows* - ```ps - ENV\Scripts\activate - ``` +| Method | Endpoint | Description | Success Code | +|--------|----------|-------------|--------------| +| GET | `/api/products` | List all products | 200 | +| POST | `/api/products` | Create a product | 201 | +| GET | `/api/products/` | Get one product | 200 | +| PUT | `/api/products/` | Update a product | 200 | +| DELETE | `/api/products/` | Delete a product | 200 | -3. Install requirements: +### Product object schema - pip install -r requirements.txt - -4. Create the database (initial set-up only) +```json +{ + "id": 1, + "name": "Paracetamol 500mg", + "unit": "box", + "unit_price": 45.00, + "description": "Pain reliever", + "supplier_id": 1 +} +``` - `python db_setup.py` +### POST / PUT request body -5. Run the application: +```json +{ + "name": "Paracetamol 500mg", // required on POST, optional on PUT + "unit": "box", // required on POST, optional on PUT + "unit_price": 45.00, // required on POST, optional on PUT + "description": "Pain reliever", // optional + "supplier_id": 1 // optional +} +``` - Using the Flask development server, in browser: `python run.py` - - As a PyWebView Desktop application: `python launch.py` +### HTTP status codes used +| Code | Meaning | +|------|---------| +| 200 | OK – successful GET, PUT, DELETE | +| 201 | Created – successful POST | +| 400 | Bad Request – missing/invalid fields | +| 404 | Not Found – product ID doesn't exist | -# Deployment (and Disclaimer) +--- -My use case is absurdly simple and probably not useful for most folks: for me this needs to run on one computer used by a couple of people and give the appeareance of a desktop application. It is not exposed to the internet, and so lacks any security features in that regard (logins, protection from CSRF, etc). +## Quickstart -[PyWebView](https://github.com/r0x0r/pywebview) does the trick of making this run as a desktop application. +```bash +# 1. Clone your fork +git clone https://github.com//simple-inventory.git +cd simple-inventory +git checkout feature/rest-api-products -# To-Do +# 2. Set up environment +python -m venv env +source env/bin/activate # Windows: env\Scripts\activate +pip install -r requirements.txt -The [Issues list](https://github.com/gassc/simple-inventory/issues) provides an overview of what's in store for this. Notably, [issue 8](https://github.com/gassc/simple-inventory/issues/8) will provide some important missing functionality for any inventory software. +# 3. Initialise the database (first time only) +python db_setup.py + +# 4. Run the app +python run.py + +# 5. Test a route +curl http://localhost:5000/api/products +``` + +--- + +## Running the Tests + +```bash +pytest tests/test_products_api.py -v --cov=api.products --cov-report=term-missing +``` + +Expected output: **25 passed, 100% coverage**. + +--- + +## Registering the Blueprint + +Add the following lines to `project/__init__.py` (inside `create_app()`): + +```python +from api.products import products_bp +app.register_blueprint(products_bp) +``` + +--- + +## Example API calls + +### Create a product +```bash +curl -X POST http://localhost:5000/api/products \ + -H "Content-Type: application/json" \ + -d '{"name":"Amoxicillin 250mg","unit":"capsule","unit_price":120.00,"supplier_id":3}' +``` + +### Update price only +```bash +curl -X PUT http://localhost:5000/api/products/1 \ + -H "Content-Type: application/json" \ + -d '{"unit_price": 135.00}' +``` + +### Delete a product +```bash +curl -X DELETE http://localhost:5000/api/products/1 +``` diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/products.py b/api/products.py new file mode 100644 index 0000000..d26e921 --- /dev/null +++ b/api/products.py @@ -0,0 +1,151 @@ +""" +REST API Blueprint: Products +Provides full CRUD operations for the inventory product resource. + +Routes: + GET /api/products - List all products + POST /api/products - Create a new product + GET /api/products/ - Get a single product + PUT /api/products/ - Update a product + DELETE /api/products/ - Delete a product +""" + +from flask import Blueprint, jsonify, request + +# db and Product are injected at app startup via project/app.py +# They are overridden in tests to use an in-memory SQLite database +db = None +Product = None + +products_bp = Blueprint("products", __name__, url_prefix="/api/products") + + +def _product_to_dict(product): + """Serialize a Product ORM object to a JSON-safe dictionary.""" + return { + "id": product.id, + "code": product.code, + "name": product.name, + "description": product.description, + "quantity_per_unit": product.quantity_per_unit, + "list_price": float(product.list_price) if product.list_price is not None else None, + "selling_price": float(product.selling_price) if product.selling_price is not None else None, + "supplier_id": product.supplier_id, + "discontinued": product.discontinued, + } + + +def _validate_product_payload(data, require_all=True): + """Validate incoming product payload. Returns a list of error messages.""" + errors = [] + + if require_all: + for field in ["name", "list_price"]: + if field not in data: + errors.append(f"Missing required field: '{field}'.") + + if "name" in data and not isinstance(data["name"], str): + errors.append("'name' must be a string.") + if "name" in data and len(str(data.get("name", "")).strip()) == 0: + errors.append("'name' must not be blank.") + + for price_field in ["list_price", "selling_price"]: + if price_field in data: + try: + price = float(data[price_field]) + if price < 0: + errors.append(f"'{price_field}' must be a non-negative number.") + except (TypeError, ValueError): + errors.append(f"'{price_field}' must be a numeric value.") + + return errors + + +@products_bp.route("", methods=["GET"]) +def list_products(): + """List all products. Returns 200 OK.""" + products = Product.query.order_by(Product.name).all() + return jsonify([_product_to_dict(p) for p in products]), 200 + + +@products_bp.route("", methods=["POST"]) +def create_product(): + """Create a new product. Returns 201 Created or 400 Bad Request.""" + data = request.get_json(silent=True) + if not data: + return jsonify({"error": "Request body must be valid JSON."}), 400 + + errors = _validate_product_payload(data, require_all=True) + if errors: + return jsonify({"errors": errors}), 400 + + product = Product( + name=data["name"].strip(), + list_price=float(data["list_price"]), + code=data.get("code"), + description=data.get("description", ""), + quantity_per_unit=data.get("quantity_per_unit"), + selling_price=float(data["selling_price"]) if "selling_price" in data else None, + supplier_id=data.get("supplier_id"), + discontinued=data.get("discontinued", False), + ) + db.session.add(product) + db.session.commit() + return jsonify(_product_to_dict(product)), 201 + + +@products_bp.route("/", methods=["GET"]) +def get_product(product_id): + """Get a single product by ID. Returns 200 OK or 404 Not Found.""" + product = Product.query.get(product_id) + if product is None: + return jsonify({"error": f"Product with id {product_id} not found."}), 404 + return jsonify(_product_to_dict(product)), 200 + + +@products_bp.route("/", methods=["PUT"]) +def update_product(product_id): + """Update a product. Returns 200 OK, 400 Bad Request, or 404 Not Found.""" + product = Product.query.get(product_id) + if product is None: + return jsonify({"error": f"Product with id {product_id} not found."}), 404 + + data = request.get_json(silent=True) + if not data: + return jsonify({"error": "Request body must be valid JSON."}), 400 + + errors = _validate_product_payload(data, require_all=False) + if errors: + return jsonify({"errors": errors}), 400 + + if "name" in data: + product.name = data["name"].strip() + if "code" in data: + product.code = data["code"] + if "description" in data: + product.description = data["description"] + if "quantity_per_unit" in data: + product.quantity_per_unit = data["quantity_per_unit"] + if "list_price" in data: + product.list_price = float(data["list_price"]) + if "selling_price" in data: + product.selling_price = float(data["selling_price"]) + if "supplier_id" in data: + product.supplier_id = data["supplier_id"] + if "discontinued" in data: + product.discontinued = data["discontinued"] + + db.session.commit() + return jsonify(_product_to_dict(product)), 200 + + +@products_bp.route("/", methods=["DELETE"]) +def delete_product(product_id): + """Delete a product by ID. Returns 200 OK or 404 Not Found.""" + product = Product.query.get(product_id) + if product is None: + return jsonify({"error": f"Product with id {product_id} not found."}), 404 + + db.session.delete(product) + db.session.commit() + return jsonify({"message": f"Product with id {product_id} successfully deleted."}), 200 diff --git a/project/app.py b/project/app.py index 311e7e5..eea4d10 100644 --- a/project/app.py +++ b/project/app.py @@ -1,4 +1,3 @@ - # ---------------------------------------------------------------------------- # Imports # ---------------------------------------------------------------------------- @@ -13,7 +12,7 @@ from flask import Flask, redirect, render_template, url_for from flask_sqlalchemy import SQLAlchemy from sqlalchemy.schema import FetchedValue -from jinja2 import Markup +from markupsafe import Markup from wtforms import validators import petl as etl from dateutil.parser import parse @@ -45,9 +44,7 @@ def format_currency(view, context, model, name): - # print("{0} - {1}".format(name, model.__dict__[name])) v = model.__dict__[name] - # print(v) if v is not None: return Markup("${:,.2f}".format(v)) else: @@ -55,36 +52,16 @@ def format_currency(view, context, model, name): def format_date(date_string, strf_string='%Y-%m-%d', replace_nonetype_with="00-00-0000"): - # if date_string is None: - # print(date_string, type(date_string)) - # return replace_nonetype_with - # else: dt = parse(date_string) return dt.strftime(strf_string) def calculate_profit(rec, assume_quantity=1): - """give a record created from a join of Sales and Product data, return profit. - If no price information is available, returns zero. If no quantity information is available, - uses the assume_quantity parameter (default = 1) - - Arguments: - rec {dict} -- a record created from a join of Sales and Product data - assume_quantity {int} -- number to use if quantity (from sale) is empty. defaults to 1 - - Returns: - gross profit from the sale, as a float - """ - - # print(rec['id'], rec['quantity'], rec['special_price'], - # rec['list_price'], rec['sold_price']) - - # determine quantity + """give a record created from a join of Sales and Product data, return profit.""" if not rec['quantity']: q = assume_quantity else: q = rec['quantity'] - # calculate profit if rec['special_price']: return round(((rec['special_price'] - rec['list_price']) * q), 2) elif rec['sold_price']: @@ -98,26 +75,11 @@ def calculate_profit(rec, assume_quantity=1): def calculate_gross_sales(rec, assume_quantity=1): - """give a record created from a join of Sales and Product data, return gross sales. - If no price information is available, returns zero. If no quantity information is available, - uses the assume_quantity parameter (default = 1) - - Arguments: - rec {dict} -- a record created from a join of Sales and Product data - assume_quantity {int} -- number to use if quantity (from sale) is empty. defaults to 1 - - Returns: - gross profit from the sale, as a float - """ - - # print(rec['id'], rec['quantity'], rec['special_price'], - # rec['list_price'], rec['sold_price']) - # determine quantity + """give a record created from a join of Sales and Product data, return gross sales.""" if not rec['quantity']: q = assume_quantity else: q = rec['quantity'] - # calculate profit if rec['special_price']: return round((rec['special_price'] * q), 2) elif rec['sold_price']: @@ -139,31 +101,15 @@ def export_data(table): dir_path = os.path.dirname(os.path.realpath(__file__)) print(dir_path) outpath = os.path.join(dir_path, 'static', 'data', "sales.csv") - etl.tocsv(table, outpath) def sales_summary(start_dt=None, end_dt=None, staff_id=None, for_export=False): - """tally up gross (sale over list) profits - TODO: tally up net profites (gross profit vs inventory purchase total) - - TODO: Keyword Arguments: - start_dt {[type]} -- datetime for start of query (default: {None}) - end_dt {[type]} -- datetime for start of query [description] (default: {None}) - - Returns: - [dict] -- various types of sales information, stored in a dictionary. - """ - - # products = db.session.query(Product).all() - # sales = db.session.query(Sale).all() - - # retrieve existing tables + """tally up gross (sale over list) profits""" products_records = etl.fromdb(db.engine, 'SELECT * FROM product') sales_records = etl.fromdb(db.engine, 'SELECT * FROM sale') staff_records = etl.fromdb(db.engine, 'SELECT * FROM staff') - # filter by start/end date if provided if start_dt and end_dt: sales_records = etl\ .selectnotnone(sales_records, 'date')\ @@ -179,26 +125,13 @@ def sales_summary(start_dt=None, end_dt=None, staff_id=None, for_export=False): else: pass - # filter by staff id if provided if staff_id: sales_records = etl.select(sales_records, 'staff_id', lambda v: v == staff_id) - # join product info to sales data sales_data = etl\ - .join( - sales_records, - products_records, - lkey='product_id', - rkey='id' - )\ - .leftjoin( - staff_records, - lkey='staff_id', - rkey='id' - ) - + .join(sales_records, products_records, lkey='product_id', rkey='id')\ + .leftjoin(staff_records, lkey='staff_id', rkey='id') - # prep joined sales data for tabulation sales_data = etl\ .convert(sales_data, 'date', lambda dt: format_date(dt))\ .sort('date')\ @@ -206,7 +139,6 @@ def sales_summary(start_dt=None, end_dt=None, staff_id=None, for_export=False): .addfield('profit', lambda rec: calculate_profit(rec))\ .addfield('gross_sales', lambda rec: calculate_gross_sales(rec)) - # tabulate some figures gross_sales = 0 profits = 0 for sale in etl.dicts(sales_data): @@ -222,35 +154,21 @@ def sales_summary(start_dt=None, end_dt=None, staff_id=None, for_export=False): export_data(sales_data) - # summarize data into charting-friendly data structures chart_count, chart_count_missing_date = etl\ .fold(sales_data, 'date', operator.add, 'quantity', presorted=True)\ .rename({'key': 'x', 'value': 'y'})\ .biselect(lambda rec: rec.x is not None) - - # print(chart_count) - # etl.lookall(chart_count) chart_gross, chart_gross_missing_date = etl\ .fold(sales_data, 'date', operator.add,'gross_sales', presorted=True)\ .rename({'key': 'x', 'value': 'y'})\ .biselect(lambda rec: rec.x is not None) - # print(chart_gross) - # etl.lookall(chart_gross) - chart_profit, chart_profit_missing_date = etl\ .fold(sales_data, 'date', operator.add, 'profit', presorted=True)\ .rename({'key': 'x', 'value': 'y'})\ .biselect(lambda rec: rec.x is not None) - - - # for i in etl.dicts(chart_count): - # print(i) - # for i in etl.dicts(chart_gross): - # print(i) - return { 'gross_sales': gross_sales, 'profits': profits, @@ -298,8 +216,6 @@ class SupplierView(ModelView): class Product(db.Model): id = db.Column(db.Integer, primary_key=True) - - # descriptive fields code = db.Column(db.String(255), unique=True) name = db.Column(db.String(255), unique=True) quantity_per_unit = db.Column(db.Integer) @@ -307,22 +223,14 @@ class Product(db.Model): selling_price = db.Column(db.Float) description = db.Column(db.Text) discontinued = db.Column(db.Boolean()) - - # REF: Supplier (brand) table supplier_id = db.Column(db.Integer(), db.ForeignKey(Supplier.id)) supplier = db.relationship(Supplier, backref='suppliers') - - # auto-completed from database trigger (concatentates several fields) fullname = db.Column( db.String(1000), server_default=FetchedValue(), server_onupdate=FetchedValue() ) - - # intial amount; managed in a separate form initial_volume = db.Column(db.Integer) - - # REF: tags table tags = db.relationship('Tag', secondary=product_tags_table) def __str__(self): @@ -334,8 +242,7 @@ class ProductView(ModelView): 'list_price': format_currency, 'selling_price': format_currency } - column_searchable_list = ('name', Supplier.name, - 'tags.name', 'fullname', 'code') + column_searchable_list = ('name', Supplier.name, 'tags.name', 'fullname', 'code') column_exclude_list = ['description', 'initial_volume', 'fullname'] column_editable_list = ['tags'] action_disallowed_list = ['delete'] @@ -345,60 +252,6 @@ class ProductView(ModelView): can_delete = False -''' -class Stock(db.Model): - id = db.Column(db.Integer, primary_key=True) - product_id = db.Column(db.Integer(), db.ForeignKey(Product.id)) - units_purchased = db.Column(db.Integer, default=1) - use_list_price = db.Column(db.Boolean(), default=False) - special_price = db.Column(db.Float) - date = db.Column(db.DateTime, default=datetime.datetime.now) - notes = db.Column(db.Text) - - def __str__(self): - return self.units_purchased - - -class Inventory(db.Model): - """Inventory is a table populated by triggers fired in the Order and Sales tables. - While directly editable, it should only need to be set-up once--for the initial - inventory--and the triggers will handle the rest. Its primary purpose is to show - how many items are left in stock - """ - id = db.Column(db.Integer, primary_key=True) - product_id = db.Column(db.Integer(), db.ForeignKey(Product.id)) - notes = db.Column(db.Text) - - # in stock = initial volume - volume sold + volume replaced - # in_stock is used to set an initial volume during table setup - in_stock = db.Column( - db.Integer, - default=0, - server_default=FetchedValue(), - server_onupdate=FetchedValue() - ) - - # total replaced = *deincremented* from Order table entries - volume_replaced = db.Column( - db.Integer, - default=0, - server_default=FetchedValue(), - server_onupdate=FetchedValue() - ) - - # total sold = *incremented* from Sale table entries - volume_sold = db.Column( - db.Integer, - default=0, - server_default=FetchedValue(), - server_onupdate=FetchedValue() - ) - - def __str__(self): - return self.in_stock -''' - - class Tag(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.Unicode(64)) @@ -415,13 +268,6 @@ def __str__(self): return self.name -''' -class StaffView(ModelView): - column_searchable_list = ('name') - action_disallowed_list = ['delete'] -''' - - class Sale(db.Model): id = db.Column(db.Integer, primary_key=True) quantity = db.Column(db.Integer, default=1) @@ -445,14 +291,13 @@ class SaleView(ModelView): 'sold_price': format_currency } column_searchable_list = (Product.fullname, Product.code, 'date') - column_exclude_list = ['notes', 'special_price', - 'fullname', 'use_list_price'] + column_exclude_list = ['notes', 'special_price', 'fullname', 'use_list_price'] form_excluded_columns = ['sold_price'] can_export = True # ---------------------------------------------------------------------------- -# Custom view classes (uses Flask-Admin template but not derived from table) +# Custom view classes # ---------------------------------------------------------------------------- class InventoryView(BaseView): @@ -480,13 +325,8 @@ def index(self): # Flask Views # ---------------------------------------------------------------------------- -# root route - - @app.route('/') def index(): - # return redirect("/admin/", code=302) - # return "

View Inventory

" return render_template('/pages/index.html') @@ -494,7 +334,6 @@ def index(): admin = admin.Admin( app, name="{0} | {1}".format(app.config['ORG_NAME'], app.config['APP_TITLE']), - template_mode='bootstrap3' ) # Add model views @@ -506,3 +345,12 @@ def index(): # add custom views admin.add_view(InventoryView(name='Inventory', endpoint='inventory')) admin.add_view(AnalyticsView(name='Analytics', endpoint='analytics')) + +# ---------------------------------------------------------------------------- +# REST API - Register products blueprint +# ---------------------------------------------------------------------------- +from api.products import products_bp +import api.products as products_module +products_module.db = db +products_module.Product = Product +app.register_blueprint(products_bp) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index c784e61..aa81efd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,5 @@ SQLAlchemy==1.1.11 Werkzeug==0.12.2 WTForms==2.1 pywebview +pytest +pytest-cov diff --git a/tests/test_products_api.py b/tests/test_products_api.py new file mode 100644 index 0000000..8374e4f --- /dev/null +++ b/tests/test_products_api.py @@ -0,0 +1,263 @@ +""" +Unit tests for the Products REST API. + +Run with: + pytest tests/test_products_api.py -v --cov=api.products +""" + +import pytest +from flask import Flask +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() + + +class Product(db.Model): + __tablename__ = "product" + id = db.Column(db.Integer, primary_key=True) + code = db.Column(db.String(255), unique=True) + name = db.Column(db.String(255), unique=True) + quantity_per_unit = db.Column(db.Integer) + list_price = db.Column(db.Float) + selling_price = db.Column(db.Float) + description = db.Column(db.Text, default="") + discontinued = db.Column(db.Boolean(), default=False) + supplier_id = db.Column(db.Integer, nullable=True) + + +def create_test_app(): + app = Flask(__name__) + app.config["TESTING"] = True + app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + + db.init_app(app) + + import api.products as api_module + api_module.db = db + api_module.Product = Product + + from api.products import products_bp + app.register_blueprint(products_bp) + + with app.app_context(): + db.create_all() + + return app + + +@pytest.fixture(scope="function") +def app(): + application = create_test_app() + yield application + with application.app_context(): + db.drop_all() + + +@pytest.fixture(scope="function") +def client(app): + return app.test_client() + + +@pytest.fixture(scope="function") +def sample_product(app): + with app.app_context(): + p = Product(name="Paracetamol 500mg", list_price=45.00, + selling_price=60.00, description="Pain reliever", + code="PARA500", quantity_per_unit=100, discontinued=False, + supplier_id=1) + db.session.add(p) + db.session.commit() + return {"id": p.id, "name": p.name, "list_price": p.list_price, + "selling_price": p.selling_price} + + +# =========================================================================== +# GET /api/products +# =========================================================================== + +class TestListProducts: + def test_returns_empty_list_when_no_products(self, client): + res = client.get("/api/products") + assert res.status_code == 200 + assert res.get_json() == [] + + def test_returns_all_products(self, client, sample_product): + res = client.get("/api/products") + assert res.status_code == 200 + assert len(res.get_json()) == 1 + + def test_returns_products_sorted_by_name(self, client, app): + with app.app_context(): + db.session.add(Product(name="Zinc Tablets", list_price=10)) + db.session.add(Product(name="Amoxicillin", list_price=15)) + db.session.commit() + res = client.get("/api/products") + names = [p["name"] for p in res.get_json()] + assert names == sorted(names) + + +# =========================================================================== +# POST /api/products +# =========================================================================== + +class TestCreateProduct: + VALID_PAYLOAD = { + "name": "Ibuprofen 200mg", + "list_price": 5.75, + "selling_price": 8.00, + "code": "IBU200", + "description": "Anti-inflammatory", + "quantity_per_unit": 50, + } + + def test_creates_product_successfully(self, client): + res = client.post("/api/products", json=self.VALID_PAYLOAD) + assert res.status_code == 201 + data = res.get_json() + assert data["name"] == "Ibuprofen 200mg" + assert data["list_price"] == 5.75 + assert "id" in data + + def test_minimal_payload_succeeds(self, client): + res = client.post("/api/products", + json={"name": "Aspirin", "list_price": 3.0}) + assert res.status_code == 201 + + def test_missing_name_returns_400(self, client): + res = client.post("/api/products", json={"list_price": 10.0}) + assert res.status_code == 400 + assert any("name" in e for e in res.get_json()["errors"]) + + def test_missing_unit_returns_400(self, client): + res = client.post("/api/products", json={"list_price": 10.0}) + assert res.status_code == 400 + + def test_missing_unit_price_returns_400(self, client): + res = client.post("/api/products", json={"name": "Drug A"}) + assert res.status_code == 400 + + def test_blank_name_returns_400(self, client): + res = client.post("/api/products", + json={"name": " ", "list_price": 5.0}) + assert res.status_code == 400 + + def test_negative_price_returns_400(self, client): + res = client.post("/api/products", + json={"name": "Drug A", "list_price": -1}) + assert res.status_code == 400 + + def test_non_numeric_price_returns_400(self, client): + res = client.post("/api/products", + json={"name": "Drug A", "list_price": "free"}) + assert res.status_code == 400 + + def test_non_json_body_returns_400(self, client): + res = client.post("/api/products", data="not json", + content_type="text/plain") + assert res.status_code == 400 + + def test_empty_body_returns_400(self, client): + res = client.post("/api/products") + assert res.status_code == 400 + + +# =========================================================================== +# GET /api/products/ +# =========================================================================== + +class TestGetProduct: + def test_get_existing_product(self, client, sample_product): + pid = sample_product["id"] + res = client.get(f"/api/products/{pid}") + assert res.status_code == 200 + assert res.get_json()["name"] == "Paracetamol 500mg" + + def test_get_nonexistent_product_returns_404(self, client): + res = client.get("/api/products/9999") + assert res.status_code == 404 + assert "not found" in res.get_json()["error"].lower() + + def test_response_contains_all_fields(self, client, sample_product): + res = client.get(f"/api/products/{sample_product['id']}") + data = res.get_json() + for field in ("id", "name", "list_price", "selling_price", + "description", "supplier_id", "discontinued", "code"): + assert field in data + + +# =========================================================================== +# PUT /api/products/ +# =========================================================================== + +class TestUpdateProduct: + def test_full_update_succeeds(self, client, sample_product): + pid = sample_product["id"] + res = client.put(f"/api/products/{pid}", + json={"name": "Updated Name", "list_price": 99.99}) + assert res.status_code == 200 + data = res.get_json() + assert data["name"] == "Updated Name" + assert data["list_price"] == 99.99 + + def test_partial_update_only_changes_supplied_fields(self, client, sample_product): + pid = sample_product["id"] + res = client.put(f"/api/products/{pid}", json={"list_price": 55.0}) + assert res.status_code == 200 + data = res.get_json() + assert data["list_price"] == 55.0 + assert data["name"] == sample_product["name"] + + def test_update_nonexistent_product_returns_404(self, client): + res = client.put("/api/products/9999", json={"name": "Ghost"}) + assert res.status_code == 404 + + def test_update_with_invalid_price_returns_400(self, client, sample_product): + pid = sample_product["id"] + res = client.put(f"/api/products/{pid}", json={"list_price": -5}) + assert res.status_code == 400 + + def test_update_with_blank_name_returns_400(self, client, sample_product): + pid = sample_product["id"] + res = client.put(f"/api/products/{pid}", json={"name": " "}) + assert res.status_code == 400 + + def test_update_with_non_json_body_returns_400(self, client, sample_product): + pid = sample_product["id"] + res = client.put(f"/api/products/{pid}", data="bad", + content_type="text/plain") + assert res.status_code == 400 + + def test_update_supplier_id(self, client, sample_product): + pid = sample_product["id"] + res = client.put(f"/api/products/{pid}", json={"supplier_id": 7}) + assert res.status_code == 200 + assert res.get_json()["supplier_id"] == 7 + + +# =========================================================================== +# DELETE /api/products/ +# =========================================================================== + +class TestDeleteProduct: + def test_delete_existing_product_returns_200(self, client, sample_product): + pid = sample_product["id"] + res = client.delete(f"/api/products/{pid}") + assert res.status_code == 200 + assert "deleted" in res.get_json()["message"].lower() + + def test_deleted_product_no_longer_accessible(self, client, sample_product): + pid = sample_product["id"] + client.delete(f"/api/products/{pid}") + res = client.get(f"/api/products/{pid}") + assert res.status_code == 404 + + def test_delete_nonexistent_product_returns_404(self, client): + res = client.delete("/api/products/9999") + assert res.status_code == 404 + + def test_double_delete_returns_404(self, client, sample_product): + pid = sample_product["id"] + client.delete(f"/api/products/{pid}") + res = client.delete(f"/api/products/{pid}") + assert res.status_code == 404