diff --git a/bitcoinstore/api/__init__.py b/bitcoinstore/api/__init__.py new file mode 100644 index 0000000..96bbc56 --- /dev/null +++ b/bitcoinstore/api/__init__.py @@ -0,0 +1,17 @@ +from flask import Blueprint +from http import HTTPStatus + +from bitcoinstore.extensions import db +from bitcoinstore.initializers import redis + +from .products import products + +api = Blueprint("api/v1", __name__) +api.register_blueprint(products, url_prefix="/products") + + +@api.get("/up") +def up(): + redis.ping() + db.engine.execute("SELECT 1") + return ("", HTTPStatus.OK) diff --git a/bitcoinstore/api/products/__init__.py b/bitcoinstore/api/products/__init__.py new file mode 100644 index 0000000..01a4d53 --- /dev/null +++ b/bitcoinstore/api/products/__init__.py @@ -0,0 +1,88 @@ +from flask import Blueprint, current_app, jsonify, request, url_for +from http import HTTPStatus +from sqlalchemy.exc import SQLAlchemyError + +from bitcoinstore.extensions import db +from bitcoinstore.models.product import Product + +from .items import items + +products = Blueprint("products", __name__) +products.register_blueprint(items, url_prefix="//items") + + +@products.post("/") +def create(): + """Create a Product + + Accepts the following parameters: + * sku (string) - stock keeping unit, must be unique + * name (string) - short name + * description (string) - long description + + On success, returns HTTP Created response, with the product's URL in the + Location response header. + """ + args = request.args + + product = Product( + sku=args.get("sku"), + name=args.get("name"), + description=args.get("description"), + ) + + try: + db.session.add(product) + db.session.commit() + except SQLAlchemyError as e: + current_app.logger.error(e) + db.session.rollback() + return (repr(e), HTTPStatus.UNPROCESSABLE_ENTITY) + + return ( + jsonify(id=product.id), + HTTPStatus.CREATED, + {"Location": url_for("api/v1.products.update", id=product.id)}, + ) + + +@products.patch("/") +def update(id): + """Update a product, by id + + Accepts the following parameters, and updates the product field if the + value is not blank: + * sku (string) - stock keeping unit, must be unique + * name (string) - short name + * description (string) - long description + * color (string) + * unit_price_subunits (integer) - price denominated in subunits of the + relevant currency, e.g. in cents for USD or in sats for BTC + * unit_price_currency (string) - the native currency for the price, denoted + in three-letter ISO 4217 currency codes for fiat, or 'BTC' for Bitcoin. + Defaults to 'USD'. + * shipping_weight_kg (decimal) - the shipping weight of the product in KG, + or decimal amounts thereof. + + On success, returns HTTP OK response. + """ + args = request.args + + product = Product.query.get_or_404(id) + + if args.get("sku"): + product.sku = args.get("sku") + if args.get("name"): + product.name = args.get("name") + if args.get("description"): + product.description = args.get("description") + + try: + db.session.add(product) + db.session.commit() + except SQLAlchemyError as e: + current_app.logger.error(e) + db.session.rollback() + return (repr(e), HTTPStatus.UNPROCESSABLE_ENTITY) + + return ("", HTTPStatus.OK) diff --git a/bitcoinstore/api/products/items/__init__.py b/bitcoinstore/api/products/items/__init__.py new file mode 100644 index 0000000..d167dc6 --- /dev/null +++ b/bitcoinstore/api/products/items/__init__.py @@ -0,0 +1,131 @@ +from decimal import Decimal +from http import HTTPStatus +from flask import Blueprint, current_app, g, jsonify, request, url_for +from sqlalchemy.exc import SQLAlchemyError + +from bitcoinstore.extensions import db +from bitcoinstore.models.product_item import ProductItem + +from .reservations import reservations + +items = Blueprint("items", __name__) +items.register_blueprint( + reservations, url_prefix="//reservations" +) + + +@items.url_value_preprocessor +def get_product_id(endpoint, values): + g.product_id = values.pop("product_id") + + +@items.post("/") +def create(): + """Create a Product Items + + Accepts the following parameters: + * serial_num (string) - serial number, must be unique for this product + * description (string) - long description + * color (string) + * unit_price_subunits (integer) - price denominated in subunits of the + relevant currency, e.g. in cents for USD or in sats for BTC + * unit_price_currency (string) - the native currency for the price, denoted + in three-letter ISO 4217 currency codes for fiat, or 'BTC' for Bitcoin. + Defaults to 'USD'. + * shipping_weight_kg (decimal) - the shipping weight of the product in KG, + or decimal amounts thereof. + * amount_in_stock (integer) - the number of available units in stock + + On success, returns HTTP Created response, with the product's URL in the + Location response header. + """ + args = request.args + + product_item = ProductItem( + product_id=g.product_id, + serial_num=args.get("serial_num"), + description=args.get("description"), + color=args.get("color"), + unit_price_subunits=args.get("unit_price_subunits", type=int), + unit_price_currency=args.get("unit_price_currency", default="USD"), + shipping_weight_kg=args.get("shipping_weight_kg", type=Decimal), + amount_in_stock=args.get("amount_in_stock", type=int), + ) + + try: + db.session.add(product_item) + db.session.commit() + except SQLAlchemyError as e: + current_app.logger.error(e) + db.session.rollback() + return (repr(e), HTTPStatus.UNPROCESSABLE_ENTITY) + + return ( + jsonify(id=product_item.id), + HTTPStatus.CREATED, + { + "Location": url_for( + "api/v1.products.items.update", + product_id=g.product_id, + id=product_item.id, + ) + }, + ) + + +@items.patch("/") +def update(id): + """Update a product item, by id + + Accepts the following parameters, and updates the product field if the + value is not blank: + * serial_num (string) - serial number, must be unique for this product + * description (string) - long description + * color (string) + * unit_price_subunits (integer) - price denominated in subunits of the + relevant currency, e.g. in cents for USD or in sats for BTC + * unit_price_currency (string) - the native currency for the price, denoted + in three-letter ISO 4217 currency codes for fiat, or 'BTC' for Bitcoin. + Defaults to 'USD'. + * shipping_weight_kg (decimal) - the shipping weight of the product in KG, + or decimal amounts thereof. + * amount_in_stock (integer) - the number of available units in stock + + On success, returns HTTP OK response. + """ + args = request.args + + product_item = ProductItem.query.filter_by( + product_id=g.product_id, id=id + ).first_or_404() + + if args.get("serial_num"): + product_item.serial_num = args.get("serial_num") + if args.get("description"): + product_item.description = args.get("description") + if args.get("color"): + product_item.color = args.get("color") + if args.get("unit_price_subunits"): + product_item.unit_price_subunits = args.get( + "unit_price_subunits", type=int + ) + if args.get("unit_price_currency"): + product_item.unit_price_currency = args.get( + "unit_price_currency", default="USD" + ) + if args.get("shipping_weight_kg"): + product_item.shipping_weight_kg = args.get( + "shipping_weight_kg", type=Decimal + ) + if args.get("amount_in_stock"): + product_item.amount_in_stock = args.get("amount_in_stock", type=int) + + try: + db.session.add(product_item) + db.session.commit() + except SQLAlchemyError as e: + current_app.logger.error(e) + db.session.rollback() + return (repr(e), HTTPStatus.UNPROCESSABLE_ENTITY) + + return ("", HTTPStatus.OK) diff --git a/bitcoinstore/api/products/items/reservations.py b/bitcoinstore/api/products/items/reservations.py new file mode 100644 index 0000000..73a3ede --- /dev/null +++ b/bitcoinstore/api/products/items/reservations.py @@ -0,0 +1,149 @@ +from http import HTTPStatus +from flask import Blueprint, current_app, g, jsonify, request, url_for +from sqlalchemy.exc import SQLAlchemyError + +from bitcoinstore.extensions import db +from bitcoinstore.models.product_item import ProductItem +from bitcoinstore.models.reservation import Reservation + +reservations = Blueprint("reservations", __name__) + + +@reservations.url_value_preprocessor +def get_product_item_id(endpoint, values): + # note product_id is loaded up the chain, in the ProductItem + # url_value_preprocessor + g.product_item_id = values.pop("product_item_id") + + +@reservations.post("/") +def create(): + """Create a Reservation for a ProductItem + + For example, when an item is added to a cart, it is reserved for the + current user. + + Accepts the following parameters: + * cart_id (integer) - cart for whom this item is being reserved + * amount (integer, default=1) - number of items to be reserved + + On success, returns HTTP Created (201) response, with the product's URL in + the Location response header. + On too few items being available, returns Request Entity Too Large (413) + """ + args = request.args + + product_item = ( + ProductItem.query.filter_by( + product_id=g.product_id, id=g.product_item_id + ) + .with_for_update(nowait=True) + .first_or_404() + ) + + # TODO: in a reasonable implementation, this endpoint would check + # for authorization to create a reservation for the given cart. + reservation = Reservation( + product_item_id=g.product_item_id, + cart_id=args.get("cart_id"), + amount=args.get("amount", type=int, default=1), + ) + + new_amount_reserved = product_item.amount_reserved + reservation.amount + if new_amount_reserved > product_item.amount_in_stock: + # Note, this response is a bit of a hack, probably a mis-use of http + # response codes. + return ( + jsonify({"amount_in_stock": product_item.amount_in_stock}), + HTTPStatus.REQUEST_ENTITY_TOO_LARGE, + ) + + product_item.amount_reserved = new_amount_reserved + try: + db.session.add(product_item) + db.session.add(reservation) + db.session.commit() + except SQLAlchemyError as e: + current_app.logger.error(e) + db.session.rollback() + return (repr(e), HTTPStatus.UNPROCESSABLE_ENTITY) + + return ( + jsonify(id=reservation.id), + HTTPStatus.CREATED, + { + "Location": url_for( + "api/v1.products.items.reservations.destroy", + product_id=g.product_id, + product_item_id=g.product_item_id, + id=reservation.id, + ) + }, + ) + + +@reservations.post("//fulfill") +def fulfill(id): + """Fulfill a reservation + + Called when a reservation is shipped, removes the reserved product from the + the amount in stock. + """ + product_item = ( + ProductItem.query.filter_by( + product_id=g.product_id, id=g.product_item_id + ) + .with_for_update(nowait=True) + .first_or_404() + ) + reservation = Reservation.query.filter_by( + product_item_id=product_item.id, id=id + ).first_or_404() + + product_item.amount_reserved -= reservation.amount + product_item.amount_in_stock -= reservation.amount + + try: + db.session.add(product_item) + db.session.commit() + except SQLAlchemyError as e: + current_app.logger.error(e) + db.session.rollback() + return (repr(e), HTTPStatus.UNPROCESSABLE_ENTITY) + + return ("", HTTPStatus.OK) + + +@reservations.delete("/") +def destroy(id): + """Cancel a reservation + + Removes the reservation and frees the item to be reserved by others. + + On success, returns HTTP OK response. + """ + product_item = ( + ProductItem.query.filter_by( + product_id=g.product_id, id=g.product_item_id + ) + .with_for_update(nowait=True) + .first_or_404() + ) + + # TODO: in a reasonable implementation, this endpoint would check the + # caller for authorization to destroy this reservation. + reservation = Reservation.query.filter_by( + product_item_id=g.product_item_id, id=id + ).first_or_404() + + product_item.amount_reserved -= reservation.amount + try: + db.session.add(product_item) + db.session.delete(reservation) + db.session.commit() + except SQLAlchemyError as e: + current_app.logger.error(e) + db.session.rollback() + return (repr(e), HTTPStatus.UNPROCESSABLE_ENTITY) + + return ("", HTTPStatus.OK) diff --git a/bitcoinstore/app.py b/bitcoinstore/app.py index a2847ca..1f7946a 100644 --- a/bitcoinstore/app.py +++ b/bitcoinstore/app.py @@ -4,6 +4,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix from bitcoinstore.page.views import page +from bitcoinstore.api import api from bitcoinstore.extensions import db from bitcoinstore.extensions import debug_toolbar from bitcoinstore.extensions import flask_static_digest @@ -52,6 +53,7 @@ def create_app(settings_override=None): middleware(app) app.register_blueprint(page) + app.register_blueprint(api, url_prefix="/api/v1") extensions(app) diff --git a/bitcoinstore/models/product.py b/bitcoinstore/models/product.py new file mode 100644 index 0000000..efb211e --- /dev/null +++ b/bitcoinstore/models/product.py @@ -0,0 +1,15 @@ +from bitcoinstore.extensions import db + + +class Product(db.Model): + """Records an product type. + Specific stocked items and variants are recorded under associated + ProductItem records. + """ + + id = db.Column(db.BigInteger, primary_key=True) + sku = db.Column(db.String, unique=True, nullable=False) + name = db.Column(db.String, nullable=False) + description = db.Column(db.Text) + + items = db.relationship("ProductItem", back_populates="product") diff --git a/bitcoinstore/models/product_item.py b/bitcoinstore/models/product_item.py new file mode 100644 index 0000000..a81ff57 --- /dev/null +++ b/bitcoinstore/models/product_item.py @@ -0,0 +1,34 @@ +from bitcoinstore.extensions import db +from .product import Product + + +class ProductItem(db.Model): + """Records an orderable variant or individual non-fungible item. + + All product items belong to a product, which is a sort of container + associating related variants/items. + """ + + id = db.Column(db.BigInteger, primary_key=True) + product_id = db.Column( + db.BigInteger, db.ForeignKey(Product.id), nullable=False + ) + product = db.relationship("Product", back_populates="items") + reservations = db.relationship( + "Reservation", + back_populates="product_item" + ) + + serial_num = db.Column(db.String) + description = db.Column(db.Text) + color = db.Column(db.String) + unit_price_subunits = db.Column(db.Integer, nullable=False) + unit_price_currency = db.Column(db.String(3), nullable=False) + shipping_weight_kg = db.Column(db.Numeric) + amount_in_stock = db.Column(db.Integer, nullable=False) + amount_reserved = db.Column(db.Integer, nullable=False, default=0) + + __table_args__ = ( + db.Index("index_serial_num", "product_id", "serial_num", unique=True), + db.CheckConstraint("amount_in_stock >= amount_reserved"), + ) diff --git a/bitcoinstore/models/reservation.py b/bitcoinstore/models/reservation.py new file mode 100644 index 0000000..9b1496c --- /dev/null +++ b/bitcoinstore/models/reservation.py @@ -0,0 +1,22 @@ +from bitcoinstore.extensions import db +from .product_item import ProductItem + + +class Reservation(db.Model): + """Records a reservation for a specific product item""" + + id = db.Column(db.BigInteger, primary_key=True) + product_item_id = db.Column( + db.BigInteger, db.ForeignKey(ProductItem.id), nullable=False + ) + product_item = db.relationship( + "ProductItem", + back_populates="reservations" + ) + + cart_id = db.Column(db.String, nullable=False) + amount = db.Column(db.Integer, nullable=False) + + created_at = db.Column( + db.DateTime, nullable=False, server_default=db.func.current_timestamp() + ) diff --git a/bitcoinstore/page/views.py b/bitcoinstore/page/views.py index cba5aef..41d2df7 100644 --- a/bitcoinstore/page/views.py +++ b/bitcoinstore/page/views.py @@ -4,9 +4,6 @@ from flask import __version__ from flask import render_template -from bitcoinstore.extensions import db -from bitcoinstore.initializers import redis - page = Blueprint("page", __name__, template_folder="templates") @@ -19,10 +16,3 @@ def home(): python_ver=os.environ["PYTHON_VERSION"], flask_env=os.environ["FLASK_ENV"], ) - - -@page.get("/up") -def up(): - redis.ping() - db.engine.execute("SELECT 1") - return "" diff --git a/requirements-lock.txt b/requirements-lock.txt index 1feeee0..a421588 100644 --- a/requirements-lock.txt +++ b/requirements-lock.txt @@ -9,8 +9,10 @@ celery==5.1.0 click==7.1.2 click-didyoumean==0.0.3 click-plugins==1.1.1 -click-repl==0.1.6 +click-repl==0.2.0 coverage==5.5 +factory-boy==3.2.0 +Faker==8.6.0 flake8==3.9.2 Flask==2.0.1 Flask-DB==0.3.1 @@ -47,6 +49,7 @@ regex==2021.4.4 six==1.16.0 SQLAlchemy==1.4.15 SQLAlchemy-Utils==0.37.4 +text-unidecode==1.3 toml==0.10.2 typed-ast==1.4.3 typing-extensions==3.10.0.0 diff --git a/requirements.txt b/requirements.txt index e33fd6d..42c8831 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,8 @@ pytest==6.2.4 pytest-cov==2.12.0 flake8==3.9.2 black==20.8b1 +factory-boy==3.2.0 +Faker==8.6.0 flask-debugtoolbar==0.11.0 Flask-Static-Digest==0.2.1 diff --git a/test/bitcoinstore/api/test_api.py b/test/bitcoinstore/api/test_api.py new file mode 100644 index 0000000..df13f63 --- /dev/null +++ b/test/bitcoinstore/api/test_api.py @@ -0,0 +1,11 @@ +from flask import url_for + +from lib.test import ViewTestMixin + + +class TestApi(ViewTestMixin): + def test_up_page(self): + """ Up page should respond with a success 200. """ + response = self.client.get(url_for("api/v1.up")) + + assert response.status_code == 200 diff --git a/test/bitcoinstore/api/test_product_item_reservations.py b/test/bitcoinstore/api/test_product_item_reservations.py new file mode 100644 index 0000000..06ed07e --- /dev/null +++ b/test/bitcoinstore/api/test_product_item_reservations.py @@ -0,0 +1,157 @@ +import json +import pytest +import werkzeug +from flask import url_for + +from lib.test import ViewTestMixin + +from test.factories import ProductItemFactory, ProductItemReservationFactory + + +class TestProductItemReservations(ViewTestMixin): + create_route = "api/v1.products.items.reservations.create" + fulfill_route = "api/v1.products.items.reservations.fulfill" + destroy_route = "api/v1.products.items.reservations.destroy" + + def test_create_without_product_id(self): + """ Create without product id returns 405. """ + with pytest.raises(werkzeug.routing.BuildError): + url_for(self.create_route) + + response = self.client.post("/products//items//reservations") + assert response.status_code == 405 + + def test_create_without_required_attrs(self): + """ Create without required item attrs returns 422.""" + product_item = ProductItemFactory.create() + response = self.client.post( + url_for( + self.create_route, + product_id=product_item.product_id, + product_item_id=product_item.id, + ) + ) + assert response.status_code == 422 + + def test_create_insufficient_available(self): + """Create without sufficient items in stock should respond with + payload too large 413. + """ + product_item = ProductItemFactory.create(amount_in_stock=3) + response = self.client.post( + url_for( + self.create_route, + product_id=product_item.product_id, + product_item_id=product_item.id, + cart_id="Foo", + amount=10, + ) + ) + assert response.status_code == 413 + + # Small requests succed, then later fail if their attempts + # push past limits + response = self.client.post( + url_for( + self.create_route, + product_id=product_item.product_id, + product_item_id=product_item.id, + cart_id="Foo", + amount=2, + ) + ) + assert response.status_code == 201 + + response = self.client.post( + url_for( + self.create_route, + product_id=product_item.product_id, + product_item_id=product_item.id, + cart_id="Foo", + amount=2, + ) + ) + assert response.status_code == 413 + + def test_create_valid(self): + """ Valid create should respond with a created 201.""" + product_item = ProductItemFactory.create(amount_in_stock=10) + response = self.client.post( + url_for( + self.create_route, + product_id=product_item.product_id, + product_item_id=product_item.id, + cart_id="Foo", + amount=2, + ) + ) + + assert response.status_code == 201 + body = json.loads(response.data) + assert response.headers["Location"] == url_for( + self.destroy_route, + product_id=product_item.product_id, + product_item_id=product_item.id, + id=body["id"], + ) + + def test_fulfill_valid(self): + """ Fulfill without required item attrs returns 422.""" + product_item = ProductItemFactory.create( + amount_in_stock=5, amount_reserved=5 + ) + reservation = ProductItemReservationFactory.create( + product_item=product_item, amount=5 + ) + response = self.client.post( + url_for( + self.fulfill_route, + product_id=product_item.product_id, + product_item_id=product_item.id, + id=reservation.id, + ) + ) + assert response.status_code == 200 + + # After fulfill, should not be able to create again + response = self.client.post( + url_for( + self.create_route, + product_id=product_item.product_id, + product_item_id=product_item.id, + cart_id="Foo", + amount=2, + ) + ) + assert response.status_code == 413 + + def test_destroy_valid(self): + """ Destroying should respond with success 200.""" + product_item = ProductItemFactory.create( + amount_in_stock=5, amount_reserved=5 + ) + reservation = ProductItemReservationFactory.create( + product_item=product_item, amount=5 + ) + response = self.client.delete( + url_for( + self.destroy_route, + product_id=product_item.product_id, + product_item_id=product_item.id, + id=reservation.id, + ) + ) + assert response.status_code == 200 + + # After destroy, should be able to create again + response = self.client.post( + url_for( + self.create_route, + product_id=product_item.product_id, + product_item_id=product_item.id, + cart_id="Foo", + amount=2, + ) + ) + + assert response.status_code == 201 diff --git a/test/bitcoinstore/api/test_product_items.py b/test/bitcoinstore/api/test_product_items.py new file mode 100644 index 0000000..5b6e8a4 --- /dev/null +++ b/test/bitcoinstore/api/test_product_items.py @@ -0,0 +1,104 @@ +from decimal import Decimal +import json +import pytest +import werkzeug +from flask import url_for + +from lib.test import ViewTestMixin + +from bitcoinstore.models.product_item import ProductItem + +from test.factories import ProductFactory, ProductItemFactory + + +class TestProductItems(ViewTestMixin): + create_route = "api/v1.products.items.create" + update_route = "api/v1.products.items.update" + + def test_create_without_product_id(self): + """ Create without product id returns 405.""" + with pytest.raises(werkzeug.routing.BuildError): + url_for(self.create_route) + + response = self.client.post("/products/items") + assert response.status_code == 405 + + def test_create_without_required_attrs(self): + """ Create without required item attrs returns 422.""" + product = ProductFactory.create() + response = self.client.post( + url_for(self.create_route, product_id=product.id) + ) + + assert response.status_code == 422 + + def test_create_valid(self): + """ Valid create should respond with a created 201.""" + product = ProductFactory.create() + response = self.client.post( + url_for( + self.create_route, + product_id=product.id, + serial_num="12341234", + description="", + color="Yellow", + unit_price_subunits=300, + shipping_weight_kg=0.1, + amount_in_stock=973, + ) + ) + + assert response.status_code == 201 + body = json.loads(response.data) + assert response.headers["Location"] == url_for( + self.update_route, product_id=product.id, id=body["id"] + ) + + product_item = ProductItem.query.get(body["id"]) + assert product_item.serial_num == "12341234" + assert product_item.description == "" + assert product_item.color == "Yellow" + assert product_item.unit_price_subunits == 300 + assert product_item.unit_price_currency == "USD" + assert product_item.shipping_weight_kg == Decimal("0.1") + assert product_item.amount_in_stock == 973 + + def test_update_without_params(self): + """ No-op updating should respond with success 200.""" + product = ProductFactory.create() + product_item = ProductItemFactory.create(product=product) + response = self.client.patch( + url_for( + self.update_route, product_id=product.id, id=product_item.id + ) + ) + + assert response.status_code == 200 + + def test_update_valid(self): + """ Updating should respond with success 200.""" + product_item = ProductItemFactory.create() + response = self.client.patch( + url_for( + self.update_route, + product_id=product_item.product_id, + id=product_item.id, + serial_num="12341234", + description="Great!", + color="Black", + unit_price_subunits=20000, + unit_price_currency="BTC", + shipping_weight_kg=0.05, + amount_in_stock=973, + ), + ) + + assert response.status_code == 200 + + assert product_item.serial_num == "12341234" + assert product_item.description == "Great!" + assert product_item.color == "Black" + assert product_item.unit_price_subunits == 20000 + assert product_item.unit_price_currency == "BTC" + assert product_item.shipping_weight_kg == Decimal("0.05") + assert product_item.amount_in_stock == 973 diff --git a/test/bitcoinstore/api/test_products.py b/test/bitcoinstore/api/test_products.py new file mode 100644 index 0000000..0a66dc7 --- /dev/null +++ b/test/bitcoinstore/api/test_products.py @@ -0,0 +1,58 @@ +import json +from flask import url_for + +from lib.test import ViewTestMixin + +from test.factories import ProductFactory + + +class TestProducts(ViewTestMixin): + create_route = "api/v1.products.create" + update_route = "api/v1.products.update" + + def test_create_without_requred_params(self): + """ Create without required attrs returns 422. """ + response = self.client.post(url_for(self.create_route)) + + assert response.status_code == 422 + + def test_create_valid(self): + """ Valid create should respond with a created 201. """ + response = self.client.post( + url_for( + self.create_route, + sku="12341234", + name="Wooden Pencil, Yellow, #2, Pre-Sharpened, 30-pack", + description="", + ) + ) + + assert response.status_code == 201 + body = json.loads(response.data) + assert response.headers["Location"] == url_for( + self.update_route, id=body["id"] + ) + + def test_update_without_params(self): + """ No-op updating should respond with success 200. """ + product = ProductFactory.create() + response = self.client.patch(url_for(self.update_route, id=product.id)) + + assert response.status_code == 200 + + def test_update_valid(self): + """ Updating should respond with success 200. """ + product = ProductFactory.create() + response = self.client.patch( + url_for( + self.update_route, + id=product.id, + ), + data=dict( + sku="12341234", + name="Pen, Black, 30-pack", + description="Great!", + ), + ) + + assert response.status_code == 200 diff --git a/test/bitcoinstore/page/test_views.py b/test/bitcoinstore/page/test_views.py index 8d64017..84cd9e8 100644 --- a/test/bitcoinstore/page/test_views.py +++ b/test/bitcoinstore/page/test_views.py @@ -9,9 +9,3 @@ def test_home_page(self): response = self.client.get(url_for("page.home")) assert response.status_code == 200 - - def test_up_page(self): - """ Up page should respond with a success 200. """ - response = self.client.get(url_for("page.up")) - - assert response.status_code == 200 diff --git a/test/factories/__init__.py b/test/factories/__init__.py new file mode 100644 index 0000000..81450d1 --- /dev/null +++ b/test/factories/__init__.py @@ -0,0 +1,67 @@ +from bitcoinstore.extensions import db +from bitcoinstore.models.product import Product +from bitcoinstore.models.product_item import ProductItem +from bitcoinstore.models.reservation import Reservation + +import factory +from faker import Factory as FakerFactory +from faker.providers import color, currency, isbn + +from random import randint, random + +faker = FakerFactory.create() +faker.add_provider(color) +faker.add_provider(currency) +faker.add_provider(isbn) + + +class ProductFactory(factory.alchemy.SQLAlchemyModelFactory): + """Product factory.""" + + sku = factory.Faker("isbn13") + name = factory.Faker("name") + description = factory.Faker("text") + + class Meta: + model = Product + sqlalchemy_session = db.session + sqlalchemy_session_persistence = "commit" + + +class ProductItemFactory(factory.alchemy.SQLAlchemyModelFactory): + """Product Item factory.""" + + # hack to generate product and id https://stackoverflow.com/a/51208287 + product = factory.SubFactory(ProductFactory) + product_id = factory.LazyAttribute(lambda o: o.product.id) + + serial_num = factory.Faker("isbn13") + description = factory.Faker("text") + color = factory.Faker("color") + unit_price_subunits = randint(1, 100000) + unit_price_currency = factory.Faker("currency_code") + shipping_weight_kg = random() + amount_in_stock = randint(1, 100) + + class Meta: + model = ProductItem + sqlalchemy_session = db.session + sqlalchemy_session_persistence = "commit" + exclude = ["product"] + + +class ProductItemReservationFactory(factory.alchemy.SQLAlchemyModelFactory): + """Product Item factory.""" + + # hack to generate product and id https://stackoverflow.com/a/51208287 + product_item = factory.SubFactory(ProductItemFactory) + product_item_id = factory.LazyAttribute(lambda o: o.product_item.id) + + cart_id = factory.Faker("isbn13") + amount = randint(1, 100) + + class Meta: + model = Reservation + sqlalchemy_session = db.session + sqlalchemy_session_persistence = "commit" + exclude = ["product_item"]