diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
new file mode 100644
index 00000000..893e5863
--- /dev/null
+++ b/.github/workflows/pytest.yml
@@ -0,0 +1,33 @@
+name: API Tests
+
+on: [push]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install dependencies
+ run: pip install -r requirements.txt
+
+ - name: Start Flask app
+ run: |
+ python app.py &
+ sleep 2
+
+ - name: Run tests
+ run: pytest test_pet.py test_store.py -v --html=report.html --self-contained-html
+
+ - name: Upload HTML report
+ uses: actions/upload-artifact@v4
+ with:
+ name: pytest-html-report
+ path: report.html
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 751d372d..d17d460c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,4 +4,26 @@ test_scratch.py
pet_scratch.py
store_scratch.py
report.html
-style.css
\ No newline at end of file
+style.css
+.idea/
+
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+venv/
+env/
+ENV/
+
+# Pytest
+.pytest_cache/
+.coverage
+htmlcov/
+
+# Ignore html report
+*.html
+reports/
+playwright-report/
+allure-results/
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 00000000..26d33521
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml
new file mode 100644
index 00000000..4ea72a91
--- /dev/null
+++ b/.idea/copilot.data.migration.agent.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 00000000..aed2a311
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
new file mode 100644
index 00000000..105ce2da
--- /dev/null
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 00000000..d0f1c06d
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 00000000..a3bbaeda
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/pytest-api-example.iml b/.idea/pytest-api-example.iml
new file mode 100644
index 00000000..73712d42
--- /dev/null
+++ b/.idea/pytest-api-example.iml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 00000000..35eb1ddf
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
deleted file mode 100644
index 339ce551..00000000
--- a/README.md
+++ /dev/null
@@ -1,50 +0,0 @@
-# Sample API Test Using Pytest and SwaggerUI
-
-## System Requirements
-
-python 3.x.x
-
-
-## Setup
-
-* Install Visual Studio Code (or any editor)
-
-https://code.visualstudio.com/download
-
-
-* Install Python 3.x.x (latest)
-
-https://www.python.org/downloads/
-
-* Create a project in vscode, open the terminal
-
-```bash
-git clone https://github.com/automationExamples/pytest-api-example.git
-pip install requests pytest pyhamcrest jsonschema pytest-html flask_restx flask
-```
-
-### Recommended vscode extensions
-
-Python, Pylance, autopep8
-
-
-## Instructions
-* You'll need to open two terminal instances, one for the local server, one to run pytest
-```bash
-python app.py
-```
-* Once it is running, you can access the SwaggerUI in a browser via http://localhost:5000 OR http://127.0.0.1:5000
-* To run the test, use the following command. When the tests complete, a 'report.html' is generated
-```bash
-pytest -v --html=report.html
-```
-* It is not expected that you complete every task, however, please give your best effort
-* You will be scored based on your ability to complete the following tasks:
-
-- [ ] Install and setup this repository on your personal computer
-- [ ] Complete the automation tasks listed below
-
-### Tasks
-- [ ] Extend and fix the 3 tests from [test_pet.py](test_pet.py#1). There are TODO instructions for each test listed in the file
-- [ ] Create the PATCH test for [test_store.py](test_store.py#1). There are TODO instructions for test along with optional tasks
-- [ ] Take note of any bugs you may have found
\ No newline at end of file
diff --git a/ReadMe.md b/ReadMe.md
new file mode 100644
index 00000000..ea61548b
--- /dev/null
+++ b/ReadMe.md
@@ -0,0 +1,69 @@
+# Petstore API
+
+A simple REST API built with Flask and flask-restx, with a pytest test suite and GitHub Actions CI.
+
+## Project Structure
+
+```
+pytest-api-example/
+├── .github/
+│ └── workflows/
+│ └── pytest.yml # GitHub Actions CI workflow
+├── app.py # Flask API
+├── api_helpers.py # HTTP helper functions for tests
+├── schemas.py # JSON schemas for response validation
+├── test_pet.py # Tests for /pets endpoints
+├── test_store.py # Tests for /store endpoints
+└── requirements.txt # Project dependencies
+```
+
+## Setup
+
+```bash
+# Create and activate virtual environment
+python -m venv venv
+source venv/bin/activate # Mac/Linux
+venv\Scripts\activate # Windows
+
+# Install dependencies
+pip install -r requirements.txt
+```
+
+## Running the API
+
+```bash
+python app.py
+```
+
+API will be available at `http://127.0.0.1:5000`
+Swagger docs at `http://127.0.0.1:5000`
+
+## Running Tests
+
+```bash
+# Run all tests
+pytest test_pet.py test_store.py -v
+
+# Run pet tests only
+pytest test_pet.py -v
+
+# Run store tests only
+pytest test_store.py -v
+```
+
+## CI/CD
+
+GitHub Actions workflow runs automatically on every push.
+Results are visible under the **Actions** tab in the repository.
+
+## Known Bugs
+
+| # | Location | Description |
+|---|---|---|
+| 1 | `schemas.py` line 9 | `name` typed as `integer` instead of `string` |
+| 2 | `app.py` line 101 | Missing f-string in `findByStatus` abort message |
+| 3 | `app.py` | `POST /pets` missing `validate=True` — accepts invalid enum values |
+| 4 | `app.py` | `POST /pets` allows client-supplied IDs |
+| 5 | `app.py` | No `DELETE` endpoints — fixed by adding DELETE to `app.py` and `api_helpers.py` |
+| 6 | `app.py` | `PATCH /order` mutates state before validating status |
+| 7 | `app.py` | `findByStatus` returns misleading error when status param is missing |
\ No newline at end of file
diff --git a/api_helpers.py b/api_helpers.py
index 62f6f0db..88eb0402 100644
--- a/api_helpers.py
+++ b/api_helpers.py
@@ -1,6 +1,6 @@
import requests
-base_url = 'http://localhost:5000'
+base_url = 'http://127.0.0.1:5000'
# GET requests
def get_api_data(endpoint, params = {}):
@@ -15,4 +15,9 @@ def post_api_data(endpoint, data):
# PATCH requests
def patch_api_data(endpoint, data):
response = requests.patch(f'{base_url}{endpoint}', json=data)
+ return response
+
+# DELETE requests
+def delete_api_data(endpoint):
+ response = requests.delete(f'{base_url}{endpoint}')
return response
\ No newline at end of file
diff --git a/app.py b/app.py
index 1925371f..c775a4b7 100644
--- a/app.py
+++ b/app.py
@@ -98,7 +98,7 @@ def get(self):
"""Find pets by status"""
status = request.args.get('status')
if status not in PET_STATUS:
- api.abort(400, 'Invalid pet status {status}')
+ api.abort(400, f'Invalid pet status {status}')
if status:
filtered_pets = [pet for pet in pets if pet['status'] == status]
return filtered_pets
@@ -161,10 +161,19 @@ def patch(self, order_id):
elif update_data['status'] == 'available':
pet['status'] = 'available'
else:
- api.abort(400, f"Invalid status '{update_data['status']}'. Valid statuses are {', '.join(PET_STATUS)}")
-
+ api.abort(400, f"Invalid status '{update_data['status']}'. Valid statuses are {', '.join(PET_STATUS)}")\
return {"message": "Order and pet status updated successfully"}
+ # ADDED: DELETE /store/order/
+ @store_ns.doc('delete_order')
+ @store_ns.response(200, 'Order deleted')
+ def delete(self, order_id):
+ """Delete an order by id"""
+ if order_id not in orders:
+ api.abort(404, "Order not found")
+ del orders[order_id]
+ return {"message": f"Order {order_id} deleted successfully"}
+
if __name__ == '__main__':
app.run(debug=True)
diff --git a/notes.txt b/notes.txt
new file mode 100644
index 00000000..105900c7
--- /dev/null
+++ b/notes.txt
@@ -0,0 +1,36 @@
+1) Bug #1 line9/schemas.py : Bug in schema declaration "name": {"type": "integer"} -> Should be "string" datatype
+ It caused ValidationError is case or running GET call for test "test_pet_schema()""
+
+2) Bug #2: line101/app.py : missing f-sting for placeholder.
+The error message would literally print {status} instead of the actual value
+
+
+3) Bug #3: Missing enum validation in POST call /pets/ The API accepts pets with invalid enum values like:
+type': 'bird', 'status': 'reserved' in payload.
+ No validate=True on @expect, so flask-restx doesn't enforce type or status enum values.
+ That why 2 last tests will fail:
+ test_pet.py::test_create_pet_invalid_enum_400[pet_data0]
+ test_pet.py::test_create_pet_invalid_enum_400[pet_data1]
+ Marked by xfail marker
+ POST /pets accepts invalid enum values
+
+
+4) BUG #4 POST /pets allows client supplied IDs
+id is not readonly=True in pet_model, so clients can pass any ID including duplicates or negatives.
+IDs should be server-generated.
+
+5) BUG #5 Missing DELETE endpoints
+DELETE /pets/ or DELETE /store/order/.
+Broke fixture teardown pets stayed pending, exhausting available pool and causing tests to skip.
+Fixed by adding DELETE endpoints to app.py and extending api_helpers.py with delete_api_data()
+
+6) Bug #6 PATCH /order mutates state before validating
+order['status'] and pet['status'] are both updated before the else: abort(400) runs.
+Invalid status partially corrupts state before the error is raised. Confirmed by test_patch_order_by_id_400 xfail.
+
+7) BUG #7 findByStatus wrong null check
+If we call GET /pets/findByStatus without a status parameter,
+instead of a helpful error like "status parameter is required",
+the API returns "Invalid pet status None" which is confusing and misleading.
+
+8) I'd all tests to test Class to run all together or added specific pytest test marker
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 00000000..ae8c6ddf
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,8 @@
+Flask==3.1.2
+flask-restx==1.3.2
+pytest==9.0.2
+pytest-html
+allure-pytest
+requests==2.32.5
+jsonschema==4.26.0
+PyHamcrest==2.1.0
\ No newline at end of file
diff --git a/schemas.py b/schemas.py
index 946cb6cc..13f2e1e3 100644
--- a/schemas.py
+++ b/schemas.py
@@ -6,7 +6,7 @@
"type": "integer"
},
"name": {
- "type": "integer"
+ "type": "string" # : Changed from "integer" to "string"
},
"type": {
"type": "string",
@@ -18,3 +18,22 @@
},
}
}
+
+
+# Add Order schema
+order = {
+ "type": "object",
+ "required": ["id", "pet_id"],
+ "properties": {
+ "id": {
+ "type": "string" # UUID string
+ },
+ "pet_id": {
+ "type": "integer"
+ },
+ "status": {
+ "type": "string",
+ "enum": ["available", "sold", "pending"]
+ }
+ }
+}
\ No newline at end of file
diff --git a/test_pet.py b/test_pet.py
index e2156781..676fbad3 100644
--- a/test_pet.py
+++ b/test_pet.py
@@ -2,7 +2,8 @@
import pytest
import schemas
import api_helpers
-from hamcrest import assert_that, contains_string, is_
+import logging
+from hamcrest import assert_that, contains_string
'''
TODO: Finish this test by...
@@ -26,21 +27,88 @@ def test_pet_schema():
3) Validate the 'status' property in the response is equal to the expected status
4) Validate the schema for each object in the response
'''
-@pytest.mark.parametrize("status", [("available")])
+
+# 1) Extending the parameterization to include all available statuses
+@pytest.mark.parametrize("status", ["available", "pending", "sold"])
def test_find_by_status_200(status):
test_endpoint = "/pets/findByStatus"
- params = {
- "status": status
- }
+ params = {"status": status}
+ # Make the API request
response = api_helpers.get_api_data(test_endpoint, params)
- # TODO...
+
+ # 2) Validate the appropriate response code
+ # Verify we got a successful response code
+ assert response.status_code == 200
+
+ # Get the list of pets from response
+ pets = response.json()
+ # Validate response is a list
+ assert isinstance(pets, list)
+
+ # 3) Validate the 'status' property in the response is equal to the expected status
+ # 4) Validate the schema for each object in the response
+ for pet in pets:
+ assert pet['status'] == status, f"Pet property status is different than {status}"
+ validate(instance=pet, schema=schemas.pet)
'''
TODO: Finish this test by...
1) Testing and validating the appropriate 404 response for /pets/{pet_id}
2) Parameterizing the test for any edge cases
'''
-def test_get_by_id_404():
- # TODO...
- pass
\ No newline at end of file
+@pytest.mark.parametrize("pet_id, description", [
+ (999, "non-existent ID"),
+ (-1, "negative ID"),
+ (9999, "large ID"),
+])
+def test_get_by_id_404(pet_id, description):
+ # Try to get a pet that doesn't exist or with invalid ID format
+ test_endpoint = f"/pets/{pet_id}"
+ response = api_helpers.get_api_data(test_endpoint)
+ # Verify we get a 404 Not Found response
+ assert response.status_code == 404, f"Expected 404 for {description}, got {response.status_code}"
+ # Try to parse JSON response if available
+ # Some 404s return JSON (API errors), others return HTML (Flask route errors)
+ try:
+ response_data = response.json()
+ assert 'message' in response_data
+ assert_that(response_data['message'], contains_string("not found"))
+ except ValueError:
+ # HTML 404 from Flask - this is expected for invalid route patterns
+ assert "404" in response.text or "Not Found" in response.text
+
+
+# Additional test for invalid ID format (string IDs)
+@pytest.mark.parametrize("pet_id", [
+ "abc", # Pure letters
+ "12test", # Numbers + letters
+ "test123", # Letters + numbers
+ "1.5", # Decimal/float string
+ "1e10", # Scientific notation
+ "pet-1", # With special characters
+ "pet_1", # With underscore
+ " ", # Single space
+ " ", # Multiple spaces
+])
+def test_get_by_invalid_id_format_404(pet_id):
+ # Try to get a pet with invalid ID format
+ test_endpoint = f"/pets/{pet_id}"
+
+ response = api_helpers.get_api_data(test_endpoint)
+
+ # Flask returns 404 for routes that don't match the int type converter
+ assert response.status_code == 404
+
+
+# Test creating pet with invalid enum values 'type' or 'status':
+@pytest.mark.xfail(reason="BUG#4 - API POST /pets/ doesn't validate enum values, allows creating pets with "
+ "invalid type or status")
+@pytest.mark.parametrize("pet_data", [
+ {'id': 100, 'name': 'test', 'type': 'bird', 'status': 'available'}, # Invalid type
+ {'id': 101, 'name': 'test', 'type': 'cat', 'status': 'reserved'}, # Invalid status
+])
+def test_create_pet_invalid_enum_400(pet_data):
+ """Test creating pet with invalid enum values"""
+ response = api_helpers.post_api_data('/pets/', pet_data)
+ assert response.status_code == 400
diff --git a/test_store.py b/test_store.py
index 186bd792..a1917289 100644
--- a/test_store.py
+++ b/test_store.py
@@ -1,16 +1,69 @@
from jsonschema import validate
import pytest
import schemas
-import api_helpers
-from hamcrest import assert_that, contains_string, is_
+from api_helpers import get_api_data, post_api_data, patch_api_data, delete_api_data
+
+
+@pytest.fixture(scope="function")
+def create_test_order():
+ """Finds an available pet and creates an order. Yields order details, cleans up after."""
+ available_pets = get_api_data('/pets/findByStatus', params={'status': 'available'}).json()
+ if not available_pets:
+ pytest.skip("No available pets to create order")
+
+ pet_id = available_pets[0]['id']
+ order_response = post_api_data('/store/order', {'pet_id': pet_id})
+ assert order_response.status_code == 201
+
+ order_id = order_response.json()['id']
+ yield {'order_id': order_id, 'pet_id': pet_id}
+
+ # Teardown - reset pet status back to available for subsequent tests
+ patch_api_data(f'/store/order/{order_id}', {'status': 'available'})
+
+ # Cleanup - delete order and pet once DELETE endpoints are implemented
+ delete_api_data(f'/store/order/{order_id}')
+ delete_api_data(f'/pets/{pet_id}')
'''
TODO: Finish this test by...
1) Creating a function to test the PATCH request /store/order/{order_id}
2) *Optional* Consider using @pytest.fixture to create unique test data for each run
-2) *Optional* Consider creating an 'Order' model in schemas.py and validating it in the test
-3) Validate the response codes and values
-4) Validate the response message "Order and pet status updated successfully"
+3) *Optional* Consider creating an 'Order' model in schemas.py and validating it in the test
+4) Validate the response codes and values
+5) Validate the response message "Order and pet status updated successfully"
'''
-def test_patch_order_by_id():
- pass
+@pytest.mark.parametrize("status", ["available", "pending", "sold"])
+def test_patch_order_by_id_200(create_test_order, status):
+ order_id = create_test_order['order_id']
+ pet_id = create_test_order['pet_id']
+
+ response = patch_api_data(f'/store/order/{order_id}', {'status': status})
+ assert response.status_code == 200, f"Expected 200, got {response.status_code}"
+ assert response.json()['message'] == "Order and pet status updated successfully"
+
+ # Verify pet status was also updated as side effect
+ pet_data = get_api_data(f'/pets/{pet_id}').json()
+ assert pet_data['status'] == status
+ validate(instance=pet_data, schema=schemas.pet)
+
+
+@pytest.mark.xfail(reason="Invalid status returns 201 instead of 400")
+def test_patch_order_by_id_400(create_test_order):
+ response = patch_api_data(
+ f'/store/order/{create_test_order["order_id"]}', {'status': 'not_available'}
+ )
+ assert response.status_code == 400, f"Expected 400, got {response.status_code}"
+
+
+def test_patch_order_by_id_404():
+ # Use a valid UUID format that won't exist in the system
+ response = patch_api_data('/store/order/00000000-0000-0000-0000-000000000000', {'status': 'sold'})
+ assert response.status_code == 404, f"Expected 404, got {response.status_code}"
+
+
+def test_order_schema(create_test_order):
+ order = {'id': create_test_order['order_id'], 'pet_id': create_test_order['pet_id']}
+ validate(instance=order, schema=schemas.order)
+ assert isinstance(order['id'], str)
+ assert len(order['id']) == 36, "UUID should be 36 characters"
\ No newline at end of file