"""Unit tests for service order workshop integration. These tests use mocked DB cursors and inventory_engine so they do not require PostgreSQL or network access. """ import os import sys POS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, POS_DIR) from unittest import mock # noqa: E402 import pytest # noqa: E402 from services import service_order_engine as engine # noqa: E402 class MockCursor: """Simple programmable cursor mock.""" def __init__(self, responses=None): self.responses = responses or [] self._calls = [] self._response_index = 0 def execute(self, sql, params=None): self._calls.append((sql, params)) def fetchone(self): if self._response_index < len(self.responses): resp = self.responses[self._response_index] self._response_index += 1 return resp return None def fetchall(self): if self._response_index < len(self.responses): resp = self.responses[self._response_index] self._response_index += 1 return resp return [] def close(self): pass class MockConn: def __init__(self, cursor): self._cursor = cursor def cursor(self): return self._cursor def commit(self): pass def rollback(self): pass @pytest.fixture def conn(): return MockConn(MockCursor()) def test_generate_order_number_first_of_year(conn): conn._cursor.responses = [(None,)] number = engine._generate_order_number(conn) assert number.startswith("SO-") assert number.endswith("-0001") def test_generate_order_number_increments(conn): conn._cursor.responses = [("SO-2026-0042",)] number = engine._generate_order_number(conn) assert number.endswith("-0043") @mock.patch("services.inventory_engine.get_stock", return_value=10) @mock.patch("services.inventory_engine.record_operation", return_value=123) def test_reserve_item_inserts_so_reserve_and_updates_quantity(mock_record, mock_stock, conn): conn._cursor.responses = [ (1, 5, 3, "pending", "SO-2026-0001"), # item lookup None, # update ] result = engine.reserve_item(conn, 7, branch_id=2, employee_id=9) assert result["reserved"] == 3 mock_stock.assert_called_once_with(conn, 5, 2) mock_record.assert_called_once() args = mock_record.call_args.args assert args[3] == "SO_RESERVE" assert args[4] == -3 @mock.patch("services.inventory_engine.get_stock", return_value=1) def test_reserve_item_raises_when_insufficient_stock(mock_stock, conn): conn._cursor.responses = [ (1, 5, 3, "pending", "SO-2026-0001"), ] with pytest.raises(ValueError, match="Insufficient stock"): engine.reserve_item(conn, 7, branch_id=2) @mock.patch("services.inventory_engine.record_operation", return_value=124) def test_release_item_restores_stock(mock_record, conn): conn._cursor.responses = [ (1, 5, 2, 2, "SO-2026-0001"), # item lookup (reserved_quantity=2) None, # update ] result = engine.release_item(conn, 7, employee_id=9) assert result["released"] == 2 args = mock_record.call_args.args assert args[3] == "SO_RELEASE" assert args[4] == 2 @mock.patch("services.inventory_engine.record_operation", return_value=125) def test_convert_to_sale_creates_sale_and_consumes_inventory(mock_record, conn): # Mock get_service_order response so = { "id": 1, "order_number": "SO-2026-0001", "status": "ready", "sale_id": None, "branch_id": 2, "customer_id": 3, "items": [ { "id": 10, "inventory_id": 5, "part_number": "BP-123", "name": "Bujia", "quantity": 2, "unit_price": 150.0, "unit_cost": 80.0, "status": "pending", "reserved_quantity": 2, } ], "labor": [ { "description": "Cambio de bujias", "hours": 1, "hourly_rate": 250, "total_cost": 250, "status": "completed", } ], } cur = conn._cursor cur.responses = [ (1, "2026-01-01 10:00:00"), # sale insert -> id=1 ] with mock.patch.object(engine, "get_service_order", return_value=so): result = engine.convert_to_sale(conn, 1, {"payment_method": "efectivo", "sale_type": "cash"}, employee_id=9) assert result["sale_id"] == 1 assert result["items_count"] == 2 assert result["total"] == pytest.approx(2 * 150 * 1.16 + 250 * 1.16, 0.01) # inventory operations: SO_RELEASE + SALE assert mock_record.call_count == 2 first = mock_record.call_args_list[0].args second = mock_record.call_args_list[1].args assert first[3] == "SO_RELEASE" assert first[4] == 2 assert second[3] == "SALE" assert second[4] == -2 def test_convert_to_sale_raises_when_already_converted(conn): so = {"status": "ready", "sale_id": 99, "branch_id": 1, "customer_id": 1, "items": [], "labor": []} conn._cursor.responses = [(1,)] with ( mock.patch.object(engine, "get_service_order", return_value=so), pytest.raises(ValueError, match="already converted"), ): engine.convert_to_sale(conn, 1, {}) def test_convert_to_sale_raises_when_cancelled(conn): so = {"status": "cancelled", "sale_id": None, "branch_id": 1, "customer_id": 1, "items": [], "labor": []} conn._cursor.responses = [(1,)] with ( mock.patch.object(engine, "get_service_order", return_value=so), pytest.raises(ValueError, match="cancelled"), ): engine.convert_to_sale(conn, 1, {}) def test_assign_mechanic_updates_employee(conn): conn._cursor.responses = [(1,), None] result = engine.assign_mechanic(conn, 1, 7) assert result["employee_id"] == 7 def test_assign_mechanic_raises_when_order_missing(conn): conn._cursor.responses = [None] with pytest.raises(ValueError, match="not found"): engine.assign_mechanic(conn, 1, 7) def test_service_catalog_crud(conn): # create conn._cursor.responses = [(1,)] result = engine.create_service_catalog_item( conn, 1, {"name": "Afinacion", "suggested_hours": 2, "suggested_rate": 300} ) assert result["id"] == 1 # list conn._cursor = MockCursor( [ [(1, 1, "Afinacion", "", 2, 300, True, None, None)], ] ) items = engine.list_service_catalog(conn) assert len(items) == 1 assert items[0]["name"] == "Afinacion" # update conn._cursor = MockCursor([None]) ok = engine.update_service_catalog_item(conn, 1, {"name": "Afinacion mayor"}) assert ok is True # delete conn._cursor = MockCursor([None]) ok = engine.delete_service_catalog_item(conn, 1) assert ok is True