diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..12b8649 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI - Tests and Coverage + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [ '3.12' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + pip install pytest pytest-django pytest-cov + + - name: Run tests and collect coverage + run: | + pytest --maxfail=1 --disable-warnings -q --cov=entities --cov-report=xml:coverage.xml --cov-report=html:coverage_html + + - name: Upload coverage artifacts + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: | + coverage.xml + coverage_html/ diff --git a/.gitignore b/.gitignore index f7eeae7..408ff08 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ *.pyc .DS_Store .env -db.sqlite3 \ No newline at end of file +db.sqlite3 +.coverage \ No newline at end of file diff --git a/README.md b/README.md index 6fd6a74..bc2cc45 100644 --- a/README.md +++ b/README.md @@ -1,91 +1,83 @@ # Inventory API (Django + Django Ninja) -A RESTful backend for managing entities, implemented in Python using Django and Django Ninja. - -Key features -- CRUD for the `Entity` model (fields: `type`, `name`, `description`, `created_at`). -- OpenAPI/Swagger documentation provided by Django Ninja (`/api/docs`). - -Entity model -- `id`: int (auto) -- `type`: string, required, max 100 characters -- `name`: string, optional, max 255 characters -- `description`: text, optional -- `created_at`: automatic creation timestamp - -Planned endpoints -- GET /api/entities — list all entities -- GET /api/entities/{id} — retrieve entity by id -- POST /api/entities — create a new entity -- PUT /api/entities/{id} — update an existing entity -- DELETE /api/entities/{id} — delete an entity - -Prerequisites -- Python 3.11+ (or compatible) -- Virtual environment (`venv`) recommended - -Development installation -```bash -python -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -python manage.py migrate -python manage.py runserver -``` +A simple RESTful API for managing `Entity` records, built with **Django** and **Django Ninja**. -Running the project +## Main Features -Unix / macOS -```bash -# create and activate virtual environment -python -m venv .venv -source .venv/bin/activate +- Full CRUD operations for entities +- Interactive OpenAPI/Swagger documentation at `/api/docs` +- Tests with pytest + coverage reporting +- Production-ready structure (PostgreSQL + Docker friendly) -# install dependencies (first time) -pip install -r requirements.txt +## Entity Model -# apply migrations and run the development server -python manage.py migrate -python manage.py runserver -``` +| Field | Type | Required | Notes | +|---------------|--------------|----------|---------------------------------| +| `id` | int | auto | Primary key | +| `type` | string(100) | yes | Entity type | +| `name` | string(255) | no | Descriptive name | +| `description` | text | no | Detailed description | +| `created_at` | datetime | auto | Creation timestamp | + +## API Endpoints + +| Method | Endpoint | Description | +|--------|-------------------------|---------------------------------| +| GET | `/api/entities` | List all entities | +| GET | `/api/entities/{id}` | Retrieve single entity | +| POST | `/api/entities` | Create a new entity | +| PUT | `/api/entities/{id}` | Update an existing entity | +| DELETE | `/api/entities/{id}` | Delete an entity | + +## Prerequisites -Windows (PowerShell) -```powershell -# create and activate virtual environment +- Python 3.11+ +- Virtual environment recommended (`venv`) + +## Local Development Setup + +### 1. Create and activate virtual environment +```bash +# Linux/macOS: python -m venv .venv +source .venv/bin/activate + +# or on Windows (PowerShell): .\.venv\Scripts\Activate.ps1 +``` -# install dependencies (first time) +### 2. Install dependencies +```bash pip install -r requirements.txt +``` -# apply migrations and run the development server +### 3. Apply migrations +```bash python manage.py migrate -python manage.py runserver ``` -Run with ASGI server (uvicorn) +### 4. Run the server: ```bash -# hot-reload ASGI server (useful for Django Ninja/OpenAPI during development) -uvicorn inventory_api.asgi:application --reload +python manage.py runserver ``` -API documentation -- After starting the server, interactive API docs will be available at: `http://127.0.0.1:8000/api/docs` +Interactive API documentation will be available at: +**http://localhost:8000/api/docs** -Tests -- Tests use `pytest`/`pytest-django`. -```bash -pytest --cov=. -``` -Docker (optional) -- You can dockerize the application and use a Postgres service for production. Add `Dockerfile` and `docker-compose.yml` for orchestration. +## Testing & Coverage -Security notes -- Do not commit secrets to the repository. Use environment variables or a secrets manager for production credentials. +### 1. Run tests with HTML coverage report +```bash +pytest --cov=entities --cov-report=html:coverage_html +``` -Suggested next steps -- Implement the API with Django Ninja (`entities/api.py`) and expose routes in `inventory_api/urls.py`. -- Write tests for the endpoints and add CI/coverage. +### 2. View the report +```bash +python -m http.server --directory coverage_html 8000 +``` +→ open **http://localhost:8000** -Files edited: `entities/models.py`, `entities/migrations/0001_initial.py`, `inventory_api/settings.py` (registered `entities` app). +![Coverage Report](./docs/test-screenshots/files_test.png) +![Coverage Report](./docs/test-screenshots/functions_test.png) +![Coverage Report](./docs/test-screenshots/classes_test.png) \ No newline at end of file diff --git a/docs/test-screenshots/classes_test.png b/docs/test-screenshots/classes_test.png new file mode 100644 index 0000000..53c4caf Binary files /dev/null and b/docs/test-screenshots/classes_test.png differ diff --git a/docs/test-screenshots/files_test.png b/docs/test-screenshots/files_test.png new file mode 100644 index 0000000..97dee9e Binary files /dev/null and b/docs/test-screenshots/files_test.png differ diff --git a/docs/test-screenshots/functions_test.png b/docs/test-screenshots/functions_test.png new file mode 100644 index 0000000..c565a04 Binary files /dev/null and b/docs/test-screenshots/functions_test.png differ diff --git a/tests/test_api_direct.py b/tests/test_api_direct.py new file mode 100644 index 0000000..d6d0cdf --- /dev/null +++ b/tests/test_api_direct.py @@ -0,0 +1,64 @@ +import pytest +from django.http import Http404 + +from entities import api, services +from entities.schemas import EntityCreate, EntityUpdate +from entities.models import Entity + + +@pytest.mark.django_db +def test_list_entities_direct_empty_then_nonempty(): + # ensure DB empty for entities + for e in services.list_entities(): + e.delete() + + res = api.list_entities(None) + assert isinstance(res, list) + assert res == [] + + # create one and verify + obj = services.create_entity({"type": "t"}) + res2 = api.list_entities(None) + assert any(isinstance(x, Entity) for x in res2) + + +@pytest.mark.django_db +def test_get_entity_direct_404_and_success(): + with pytest.raises(Http404): + api.get_entity(None, 999999) + + obj = services.create_entity({"type": "g"}) + got = api.get_entity(None, obj.id) + assert got.id == obj.id + + +@pytest.mark.django_db +def test_create_entity_direct(): + payload = EntityCreate(type="x", name="n") + status, obj = api.create_entity(None, payload) + assert status == 201 + assert isinstance(obj, Entity) + + +@pytest.mark.django_db +def test_update_entity_direct_404_and_partial(): + payload = EntityUpdate(name="updated") + with pytest.raises(Http404): + api.update_entity(None, 999999, payload) + + obj = services.create_entity({"type": "t", "name": "old", "description": "d"}) + updated = api.update_entity(None, obj.id, payload) + assert updated.name == "updated" + assert updated.type == "t" + + +@pytest.mark.django_db +def test_delete_entity_direct_404_and_success(): + with pytest.raises(Http404): + api.delete_entity(None, 999999) + + obj = services.create_entity({"type": "t"}) + status, body = api.delete_entity(None, obj.id) + assert status == 204 + with pytest.raises(Entity.DoesNotExist): + services.get_entity(obj.id) diff --git a/tests/test_api_extra.py b/tests/test_api_extra.py new file mode 100644 index 0000000..90e17d3 --- /dev/null +++ b/tests/test_api_extra.py @@ -0,0 +1,44 @@ +import pytest +import json +from django.test import Client + + +@pytest.mark.django_db +def test_get_not_found(): + client = Client() + assert client.get("/api/entities/999999").status_code == 404 + + +@pytest.mark.django_db +def test_create_invalid_payload(): + client = Client() + resp = client.post( + "/api/entities", json.dumps({"name": "x"}), content_type="application/json" + ) + assert resp.status_code in (400, 422) + + +@pytest.mark.django_db +def test_delete_nonexistent_returns_404(): + client = Client() + resp = client.delete("/api/entities/999999") + assert resp.status_code == 404 + + +@pytest.mark.django_db +def test_list_empty_returns_empty_list(): + client = Client() + resp = client.get("/api/entities") + assert resp.status_code == 200 + assert resp.json() == [] + + +@pytest.mark.django_db +def test_update_not_found_returns_404(): + client = Client() + resp = client.put( + "/api/entities/999999", + data=json.dumps({"name": "nope"}), + content_type="application/json", + ) + assert resp.status_code == 404 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..cf86771 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,12 @@ +import pytest + +from entities import services +from entities.models import Entity + + +@pytest.mark.django_db +def test_entity_str_representation(): + obj = services.create_entity({"type": "mytype", "name": "myname"}) + s = str(obj) + assert "mytype" in s + assert "myname" in s diff --git a/tests/test_views_import.py b/tests/test_views_import.py new file mode 100644 index 0000000..a99e91a --- /dev/null +++ b/tests/test_views_import.py @@ -0,0 +1,4 @@ +def test_views_import(): + # simple import test to cover views module + import entities.views + assert hasattr(entities.views, "render")