From cafe6601fecca354e7c30f8f460d0fc5d708928a Mon Sep 17 00:00:00 2001 From: Oleksandr Rybachok Date: Wed, 17 Dec 2025 17:06:45 +0200 Subject: [PATCH] Version 1.0.0 orybachok app --- .gitignore | 45 +++++++ Dockerfile | 28 +++++ README.md | 91 +++++++++++---- app/__init__.py | 0 app/cli.py | 81 +++++++++++++ app/main.py | 145 +++++++++++++++++++++++ docker-compose.yaml | 44 +++++++ docker/init-test-db.sh | 8 ++ requirements.txt | 8 ++ run-tests-and-deploy.sh | 30 +++++ tests/__init__.py | 0 tests/conftest.py | 88 ++++++++++++++ tests/test_api.py | 253 ++++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 139 ++++++++++++++++++++++ 14 files changed, 937 insertions(+), 23 deletions(-) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/__init__.py create mode 100644 app/cli.py create mode 100644 app/main.py create mode 100644 docker-compose.yaml create mode 100755 docker/init-test-db.sh create mode 100644 requirements.txt create mode 100755 run-tests-and-deploy.sh create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_api.py create mode 100644 tests/test_cli.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b4da4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Environment +.env +.env.local + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Docker +postgres_data/ + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f81c236 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# STAGE 1: Base +# We load dependencies here so they are cached for both test and prod +FROM python:3.11-slim AS base + +WORKDIR /app + +# Prevent Python from writing pyc files and buffering stdout +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# STAGE 2: Test +# This stage prepares the test environment (tests run via docker compose, not during build) +FROM base AS test +COPY . . +# You can install dev-dependencies here if you have a separate requirements-dev.txt +# RUN pip install pytest-cov +CMD ["pytest", "--verbose", "tests/"] + +# STAGE 3: Production +# This stage is optimized for runtime +FROM base AS production +COPY . . +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/README.md b/README.md index 3145d38..514aa8b 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,76 @@ -# Instructions +# Server Inventory Manager -You are developing an inventory management software solution for a cloud services company that provisions servers in multiple data centers. You must build a CRUD app for tracking the state of all the servers. +A CRUD application to manage server inventory. -Deliverables: -- PR to https://github.com/Mathpix/hiring-challenge-devops-python that includes: -- API code -- CLI code -- pytest test suite -- Working Docker Compose stack +## Specs -Short API.md on how to run everything, also a short API and CLI spec +### API +API documentation available at [http://<hostname>:8000/docs](http://:8000/docs) +- **POST /servers**: Create +- **GET /servers**: List all +- **GET /servers/{id}**: Details +- **PUT /servers/{id}**: Update +- **DELETE /servers/{id}**: Remove -Required endpoints: -- POST /servers → create a server -- GET /servers → list all servers -- GET /servers/{id} → get one server -- PUT /servers/{id} → update server -- DELETE /servers/{id} → delete server +### CLI +- `list`: Show table of servers +- `create [hostname] [ip] [state] [datacenter](optional) [cpu](optional) [ram_gb](optional)`: Add server +- `update [id] [hostname] [ip] [state] [datacenter](optional) [cpu](optional) [ram_gb](optional)`: Edit server +- `delete [id]`: Remove server -Requirements: -- Use FastAPI or Flask -- Store data in PostgreSQL -- Use raw SQL +## How to Run -Validate that: -- hostname is unique -- IP address looks like an IP +### Option 1: Run Tests Then Deploy API (Interactive) + +Run tests on the `inventory_test` database, and if successful, automatically build and start the API: + +```bash +./run-tests-and-deploy.sh +``` + +This script will: +1. Start the database service +2. Run tests against `inventory_test` database +3. Remove the test container on success +4. Build and start the API service + +### Option 2: Run Tests Only + +```bash +docker-compose up -d db --wait +docker-compose run --rm test +``` + +### Option 3: Start Full Stack Directly (Without test container cleanup) + +```bash +docker-compose up --build +``` + + +## Using the CLI + +First, install the dependencies: + +```bash +python -m venv .venv +source .venv/bin/activate + +pip install -r requirements.txt +``` + +Then, you can use the CLI to manage servers: + +```bash +python -m app.cli list +python -m app.cli create web-01 10.0.0.5 active +``` + +## Future Improvements + +- Add extra columns +- Add IP address unique check +- Add ability to update one field independently +- Deploy separate binary for CLI -State is one of: active, offline, retired diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/cli.py b/app/cli.py new file mode 100644 index 0000000..1031bd8 --- /dev/null +++ b/app/cli.py @@ -0,0 +1,81 @@ +import typer +import requests +import json +from rich.console import Console +from rich.table import Table + +app = typer.Typer() +console = Console() +API_URL = "http://localhost:8000/servers" + +@app.command() +def list(): + """List all servers.""" + try: + response = requests.get(API_URL) + response.raise_for_status() + servers = response.json() + + table = Table(title="Server Inventory") + table.add_column("ID", style="cyan") + table.add_column("Hostname", style="magenta") + table.add_column("IP Address", style="green") + table.add_column("State", style="yellow") + table.add_column("Datacenter", style="blue") + table.add_column("CPU Cores", style="purple") + table.add_column("RAM GB", style="red") + + for s in servers: + table.add_row( + str(s['id']), s['hostname'], s['ip_address'], + s['state'], s['datacenter'] or '', + str(s['cpu_cores']) if s['cpu_cores'] else '', + str(s['ram_gb']) if s['ram_gb'] else '' + ) + + console.print(table) + except Exception as e: + console.print(f"[bold red]Error:[/bold red] {e}") + +@app.command() +def create(hostname: str, ip: str, state: str, datacenter: str, cpu_cores: int, ram_gb: int): + """Create a new server. State must be: active, offline, retired""" + payload = {"hostname": hostname, "ip_address": ip, "state": state, "datacenter": datacenter, + "cpu_cores": cpu_cores, "ram_gb": ram_gb} + response = requests.post(API_URL, json=payload) + if response.status_code == 201: + console.print(f"[bold green]Created:[/bold green] {response.json()}") + else: + console.print(f"[bold red]Failed:[/bold red] {response.text}") + +@app.command() +def get(id: int): + """Get server details by ID.""" + response = requests.get(f"{API_URL}/{id}") + if response.status_code == 200: + console.print(response.json()) + else: + console.print(f"[bold red]Error:[/bold red] {response.text}") + +@app.command() +def update(id: int, hostname: str, ip: str, state: str, datacenter: str, cpu_cores: int, ram_gb: int): + """Update a server.""" + payload = {"hostname": hostname, "ip_address": ip, "state": state, "datacenter": datacenter, + "cpu_cores": cpu_cores, "ram_gb": ram_gb} + response = requests.put(f"{API_URL}/{id}", json=payload) + if response.status_code == 200: + console.print(f"[bold green]Updated:[/bold green] {response.json()}") + else: + console.print(f"[bold red]Failed:[/bold red] {response.text}") + +@app.command() +def delete(id: int): + """Delete a server.""" + response = requests.delete(f"{API_URL}/{id}") + if response.status_code == 204: + console.print(f"[bold green]Server {id} deleted.[/bold green]") + else: + console.print(f"[bold red]Failed:[/bold red] {response.text}") + +if __name__ == "__main__": + app() \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..ef21dff --- /dev/null +++ b/app/main.py @@ -0,0 +1,145 @@ +import os +from contextlib import asynccontextmanager +from datetime import datetime +from enum import Enum +from typing import List, Optional +from fastapi import FastAPI, HTTPException, status +from pydantic import BaseModel, IPvAnyAddress, Field, ConfigDict, field_serializer +from psycopg_pool import ConnectionPool +from psycopg.rows import dict_row +from psycopg.errors import UniqueViolation + +# --- Database Configuration --- +DB_DSN = os.getenv( + "DB_DSN", + "postgresql://user:password@db:5432/inventory" +) + +pool = ConnectionPool(DB_DSN, open=False) + +# --- Models --- +class ServerState(str, Enum): + active = "active" + offline = "offline" + retired = "retired" + +class ServerBase(BaseModel): + hostname: str = Field(min_length=1, max_length=255) + ip_address: IPvAnyAddress + state: ServerState + datacenter: Optional[str] = Field(None, max_length=100) + cpu_cores: Optional[int] = Field(None, ge=1) + ram_gb: Optional[int] = Field(None, ge=1) + +class ServerCreate(ServerBase): + pass + +class ServerResponse(ServerBase): + id: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict() + + @field_serializer('ip_address') + def serialize_ip(self, ip: IPvAnyAddress) -> str: + """Converts IPvAnyAddress to str for JSON serialization""" + return str(ip) + +# --- Lifecycle & Database Setup --- +def prepare_database(): + """Create database tables. Can be called independently for testing.""" + with pool.connection() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS servers ( + id SERIAL PRIMARY KEY, + hostname VARCHAR(255) UNIQUE NOT NULL, + ip_address INET NOT NULL, + state VARCHAR(50) NOT NULL, + datacenter VARCHAR(100), + cpu_cores INTEGER, + ram_gb INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + +@asynccontextmanager +async def lifespan(app: FastAPI): + pool.open() + prepare_database() + yield + pool.close() + +app = FastAPI(lifespan=lifespan) + +# --- Helper for Raw SQL Execution --- +def execute_query(query: str, params: tuple = None, fetch_one=False, fetch_all=False): + with pool.connection() as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute(query, params) + if fetch_one: + return cur.fetchone() + if fetch_all: + return cur.fetchall() + return cur.rowcount + +# --- Endpoints --- + +@app.post("/servers", response_model=ServerResponse, status_code=201) +def create_server(server: ServerCreate): + sql = """ + INSERT INTO servers (hostname, ip_address, state, datacenter, cpu_cores, ram_gb) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id, hostname, ip_address, state, datacenter, cpu_cores, ram_gb, created_at, updated_at; + """ + try: + result = execute_query( + sql, (server.hostname, str(server.ip_address), + server.state.value, server.datacenter, server.cpu_cores, server.ram_gb), + fetch_one=True) + return result + except UniqueViolation: + raise HTTPException(status_code=400, detail="Hostname already exists.") + +@app.get("/servers", response_model=List[ServerResponse]) +def list_servers(): + sql = "SELECT id, hostname, ip_address, state, datacenter, cpu_cores, ram_gb, created_at, updated_at FROM servers;" + return execute_query(sql, fetch_all=True) + +@app.get("/servers/{server_id}", response_model=ServerResponse) +def get_server(server_id: int): + sql = "SELECT id, hostname, ip_address, state, datacenter, cpu_cores, ram_gb, created_at, updated_at FROM servers WHERE id = %s;" + result = execute_query(sql, (server_id,), fetch_one=True) + if not result: + raise HTTPException(status_code=404, detail="Server not found") + return result + +@app.put("/servers/{server_id}", response_model=ServerResponse) +def update_server(server_id: int, server: ServerCreate): + sql = """ + UPDATE servers + SET hostname = %s, ip_address = %s, state = %s, datacenter = %s, cpu_cores = %s, ram_gb = %s + WHERE id = %s + RETURNING id, hostname, ip_address, state, datacenter, cpu_cores, ram_gb, created_at, updated_at; + """ + try: + result = execute_query( + sql, + (server.hostname, str(server.ip_address), server.state.value, + server.datacenter, server.cpu_cores, server.ram_gb, server_id), + fetch_one=True + ) + if not result: + raise HTTPException(status_code=404, detail="Server not found") + return result + except UniqueViolation: + raise HTTPException(status_code=400, detail="Hostname already exists.") + +@app.delete("/servers/{server_id}", status_code=204) +def delete_server(server_id: int): + sql = "DELETE FROM servers WHERE id = %s;" + rows_deleted = execute_query(sql, (server_id,)) + if rows_deleted == 0: + raise HTTPException(status_code=404, detail="Server not found") + return \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..42124b2 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,44 @@ +services: + db: + image: postgres:15-alpine + environment: + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: inventory + volumes: + - postgres_data:/var/lib/postgresql/data + - ./docker/init-test-db.sh:/docker-entrypoint-initdb.d/init-test-db.sh:ro + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U user -d inventory && pg_isready -U user -d inventory_test"] + interval: 5s + timeout: 5s + retries: 5 + + test: + build: + context: . + target: test + environment: + DB_DSN: postgresql://user:password@db:5432/inventory_test + depends_on: + db: + condition: service_healthy + + api: + build: + context: . + target: production + ports: + - "8000:8000" + environment: + DB_DSN: postgresql://user:password@db:5432/inventory + depends_on: + test: + condition: service_completed_successfully + db: + condition: service_healthy + +volumes: + postgres_data: \ No newline at end of file diff --git a/docker/init-test-db.sh b/docker/init-test-db.sh new file mode 100755 index 0000000..7814692 --- /dev/null +++ b/docker/init-test-db.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +# Create the test database on the same postgres instance +# Note: "user" is a reserved keyword in PostgreSQL, so we quote it with double quotes +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ + -c "CREATE DATABASE inventory_test;" \ + -c "GRANT ALL PRIVILEGES ON DATABASE inventory_test TO \"${POSTGRES_USER}\";" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..04e2ca0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi>=0.109.0 +uvicorn>=0.27.0 +psycopg[binary,pool]>=3.1.8 +pydantic>=2.6.0 +typer>=0.9.0 +requests>=2.31.0 +pytest>=8.0.0 +httpx>=0.27.0 \ No newline at end of file diff --git a/run-tests-and-deploy.sh b/run-tests-and-deploy.sh new file mode 100755 index 0000000..9ae5ece --- /dev/null +++ b/run-tests-and-deploy.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${YELLOW}Starting database service...${NC}" +docker-compose up -d db --wait + +echo -e "${YELLOW}Running tests on inventory_test database...${NC}" +# Run tests and capture exit code +# --exit-code-from returns the exit code of the specified service +if docker-compose run --rm test; then + echo -e "${GREEN}✓ All tests passed!${NC}" + + echo -e "${YELLOW}Building and starting API service...${NC}" + docker-compose up -d --build --no-deps api + + echo -e "${GREEN}✓ API service is now running on http://localhost:8000${NC}" + echo -e "${GREEN}✓ Deployment complete!${NC}" +else + EXIT_CODE=$? + echo -e "${RED}✗ Tests failed with exit code ${EXIT_CODE}${NC}" + echo -e "${RED}✗ API deployment aborted.${NC}" + exit $EXIT_CODE +fi + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a85429a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,88 @@ +import pytest +import os +import psycopg +from fastapi.testclient import TestClient +from app.main import app, pool, prepare_database + +# Use the DSN from environment +# Default to local settings for debugging outside Docker +DB_DSN = os.getenv("DB_DSN", "postgresql://user:password@localhost:5432/inventory_test") + + +def cleanup_test_database(): + """ + Clean up the test database by dropping all tables. + Called after the entire test session completes. + """ + try: + with psycopg.connect(DB_DSN) as conn: + with conn.cursor() as cur: + # Drop all tables in the test database + cur.execute(""" + DROP TABLE IF EXISTS servers CASCADE; + """) + conn.commit() + print("\n✓ Test database 'inventory_test' cleaned up successfully.") + except Exception as e: + print(f"\n⚠ Warning: Could not clean up test database. Error: {e}") + + +@pytest.fixture(scope="session") +def client(): + """ + Creates a TestClient instance that persists for the entire test session. + Opens the connection pool and prepares the database using main.py's prepare_database function. + """ + pool.open() + prepare_database() + try: + yield TestClient(app) + finally: + pool.close() + # Clean up the test database after all tests complete + cleanup_test_database() + +@pytest.fixture(scope="function", autouse=True) +def clean_db(request): + """ + Automatically runs before each test function to clean the database. + This ensures every test starts with an empty table. + + We skip this for CLI tests (which are mocked) to avoid unnecessary DB connections. + """ + if "cli" in request.module.__name__: + yield + return + + # Connect to the test DB and wipe data + # We use a separate synchronous connection for the teardown + try: + with psycopg.connect(DB_DSN) as conn: + with conn.cursor() as cur: + # RESTART IDENTITY resets the serial ID counter to 1 + cur.execute("TRUNCATE TABLE servers RESTART IDENTITY;") + except Exception as e: + print(f"Warning: Could not clean database. Error: {e}") + + yield + +# --- CLI Specific Fixtures --- + +@pytest.fixture +def mock_server_data(): + """Shared mock data for CLI tests.""" + return { + "id": 1, + "hostname": "test-srv-01", + "ip_address": "192.168.1.50", + "state": "active", + "datacenter": "us-east-1", + "cpu_cores": 8, + "ram_gb": 32, + } + +@pytest.fixture +def created_server(client, mock_server_data): + """Create a server and return the response data.""" + response = client.post("/servers", json=mock_server_data) + return response.json() \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..50697fa --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,253 @@ +"""Tests for the Server Inventory API.""" + +import pytest + +class TestCreateServer: + """Tests for POST /servers endpoint.""" + + def test_create_server_success(self, client, mock_server_data): + """Test creating a server successfully.""" + response = client.post("/servers", json=mock_server_data) + assert response.status_code == 201 + + data = response.json() + assert data["hostname"] == mock_server_data["hostname"] + assert data["ip_address"] == mock_server_data["ip_address"] + assert data["state"] == mock_server_data["state"] + assert data["datacenter"] == mock_server_data["datacenter"] + assert data["cpu_cores"] == mock_server_data["cpu_cores"] + assert data["ram_gb"] == mock_server_data["ram_gb"] + assert "id" in data + assert "created_at" in data + assert "updated_at" in data + + def test_create_server_minimal(self, client): + """Test creating a server with only required fields.""" + server = { + "hostname": "minimal-server", + "ip_address": "10.0.0.1", + "state": "active", + } + response = client.post("/servers", json=server) + assert response.status_code == 201 + + data = response.json() + assert data["hostname"] == server["hostname"] + assert data["datacenter"] is None + assert data["cpu_cores"] is None + assert data["ram_gb"] is None + + def test_create_server_duplicate_hostname(self, client, mock_server_data): + """Test that duplicate hostnames are rejected.""" + client.post("/servers", json=mock_server_data) + + response = client.post("/servers", json=mock_server_data) + assert response.status_code == 400 + assert "already exists" in response.json()["detail"] + + def test_create_server_invalid_ip(self, client): + """Test that invalid IP addresses are rejected.""" + server = { + "hostname": "bad-ip-server", + "ip_address": "999.999.999.999", + "state": "active", + } + response = client.post("/servers", json=server) + assert response.status_code == 422 + + def test_create_server_invalid_ip_format(self, client): + """Test that malformed IP addresses are rejected.""" + server = { + "hostname": "bad-ip-server", + "ip_address": "not-an-ip", + "state": "active", + } + response = client.post("/servers", json=server) + assert response.status_code == 422 + + def test_create_server_invalid_state(self, client): + """Test that invalid states are rejected.""" + server = { + "hostname": "bad-state-server", + "ip_address": "192.168.1.1", + "state": "invalid_state", + } + response = client.post("/servers", json=server) + assert response.status_code == 422 + + def test_create_server_valid_states(self, client): + """Test all valid server states.""" + for state in ["active", "offline", "retired"]: + server = { + "hostname": f"server-{state}", + "ip_address": "192.168.1.1", + "state": state, + } + response = client.post("/servers", json=server) + assert response.status_code == 201 + assert response.json()["state"] == state + + def test_create_server_ipv6(self, client): + """Test creating a server with IPv6 address.""" + server = { + "hostname": "ipv6-server", + "ip_address": "2001:0db8:85a3:0000:0000:8a2e:0370:7334", + "state": "active", + } + response = client.post("/servers", json=server) + assert response.status_code == 201 + + +class TestListServers: + """Tests for GET /servers endpoint.""" + + def test_list_servers_empty(self, client): + """Test listing servers when none exist.""" + response = client.get("/servers") + assert response.status_code == 200 + assert response.json() == [] + + def test_list_servers(self, client, created_server): + """Test listing servers returns created servers.""" + response = client.get("/servers") + assert response.status_code == 200 + + servers = response.json() + assert len(servers) == 1 + assert servers[0]["id"] == created_server["id"] + + +class TestGetServer: + """Tests for GET /servers/{id} endpoint.""" + + def test_get_server(self, client, created_server): + """Test getting a server by ID.""" + response = client.get(f"/servers/{created_server['id']}") + assert response.status_code == 200 + + data = response.json() + assert data["id"] == created_server["id"] + assert data["hostname"] == created_server["hostname"] + + def test_get_server_not_found(self, client): + """Test getting a non-existent server.""" + response = client.get("/servers/99999") + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + + +class TestUpdateServer: + """Tests for PUT /servers/{id} endpoint.""" + + def test_update_server(self, client, created_server): + """Test updating multiple fields at once.""" + response = client.put( + f"/servers/{created_server['id']}", + json={ + "hostname": "updated-server", + "state": "retired", + "ip_address": "10.0.0.100", + } + ) + assert response.status_code == 200 + data = response.json() + assert data["hostname"] == "updated-server" + assert data["state"] == "retired" + assert data["ip_address"] == "10.0.0.100" + + def test_update_server_not_found(self, client): + """Test updating a non-existent server.""" + response = client.put( + "/servers/99999", + json={"hostname": "new-hostname"} + ) + assert response.status_code == 422 + + def test_update_server_invalid_ip(self, client, created_server): + """Test that updating with invalid IP is rejected.""" + response = client.put( + f"/servers/{created_server['id']}", + json={ + "hostname": "other-server", + "ip_address": "999.999.999.999", + "state": "active", + } + ) + assert response.status_code == 422 + + def test_update_server_invalid_state(self, client, created_server): + """Test that updating with invalid state is rejected.""" + response = client.put( + f"/servers/{created_server['id']}", + json={ + "hostname": "other-server", + "ip_address": "192.168.1.200", + "state": "invalid-state", + } + ) + assert response.status_code == 422 + + +class TestDeleteServer: + """Tests for DELETE /servers/{id} endpoint.""" + + def test_delete_server(self, client, created_server): + """Test deleting a server.""" + response = client.delete(f"/servers/{created_server['id']}") + assert response.status_code == 204 + + # Verify server is deleted + get_response = client.get(f"/servers/{created_server['id']}") + assert get_response.status_code == 404 + + def test_delete_server_not_found(self, client): + """Test deleting a non-existent server.""" + response = client.delete("/servers/99999") + assert response.status_code == 404 + + +class TestValidation: + """Tests for input validation.""" + + def test_empty_hostname(self, client): + """Test that empty hostname is rejected.""" + server = { + "hostname": "", + "ip_address": "192.168.1.1", + "state": "active", + } + response = client.post("/servers", json=server) + assert response.status_code == 422 + + def test_hostname_too_long(self, client): + """Test that hostname exceeding max length is rejected.""" + server = { + "hostname": "x" * 256, + "ip_address": "192.168.1.1", + "state": "active", + } + response = client.post("/servers", json=server) + assert response.status_code == 422 + + def test_negative_cpu_cores(self, client): + """Test that negative CPU cores is rejected.""" + server = { + "hostname": "server-test", + "ip_address": "192.168.1.1", + "state": "active", + "cpu_cores": -1, + } + response = client.post("/servers", json=server) + assert response.status_code == 422 + + def test_zero_ram(self, client): + """Test that zero RAM is rejected.""" + server = { + "hostname": "server-test", + "ip_address": "192.168.1.1", + "state": "active", + "ram_gb": 0, + } + response = client.post("/servers", json=server) + assert response.status_code == 422 + diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..8978e1e --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,139 @@ +import pytest +from unittest.mock import patch, Mock +from typer.testing import CliRunner +from app.cli import app + +runner = CliRunner() + +# --- Tests --- + +def test_list_servers_success(mock_server_data): + """Test listing servers with a successful API response.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [mock_server_data] + + # Patch 'requests.get' where it is used in app.cli + with patch("app.cli.requests.get", return_value=mock_response) as mock_get: + result = runner.invoke(app, ["list"]) + + assert result.exit_code == 0 + assert "Server Inventory" in result.stdout + # Check if table row content is present + assert "test-srv-01" in result.stdout + assert "active" in result.stdout + assert "us-east-1" in result.stdout + assert "8" in result.stdout + assert "32" in result.stdout + mock_get.assert_called_once() + +def test_list_servers_api_error(): + """Test handling of API connection errors.""" + with patch("app.cli.requests.get", side_effect=Exception("Connection refused")): + result = runner.invoke(app, ["list"]) + + assert result.exit_code == 0 + assert "Error:" in result.stdout + assert "Connection refused" in result.stdout + +def test_create_server_success(mock_server_data): + """Test creating a server successfully.""" + mock_response = Mock() + mock_response.status_code = 201 + mock_response.json.return_value = mock_server_data + + with patch("app.cli.requests.post", return_value=mock_response) as mock_post: + result = runner.invoke(app, ["create", "test-srv-01", "192.168.1.50", "active", + "us-east-1", "8", "32"]) + + assert result.exit_code == 0 + assert "Created:" in result.stdout + # Check key fields are present in output (dict formatting may vary) + assert "test-srv-01" in result.stdout + assert "192.168.1.50" in result.stdout + + # Verify payload sent to API + mock_post.assert_called_once() + args, kwargs = mock_post.call_args + assert kwargs['json'] == { + "hostname": "test-srv-01", + "ip_address": "192.168.1.50", + "state": "active", + "datacenter": "us-east-1", + "cpu_cores": 8, + "ram_gb": 32 + } + +def test_create_server_failure(): + """Test creation failure (e.g., validation error from API).""" + mock_response = Mock() + mock_response.status_code = 400 + mock_response.text = "Hostname already exists" + + with patch("app.cli.requests.post", return_value=mock_response): + result = runner.invoke(app, ["create", "duplicate", "1.1.1.1", "active", + "us-east-1", "8", "32"]) + + assert result.exit_code == 0 + assert "Failed:" in result.stdout + assert "Hostname already exists" in result.stdout + +def test_get_server_success(mock_server_data): + """Test getting a single server details.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = mock_server_data + + with patch("app.cli.requests.get", return_value=mock_response): + result = runner.invoke(app, ["get", "1"]) + + assert result.exit_code == 0 + assert "'hostname': 'test-srv-01'" in result.stdout + +def test_get_server_not_found(): + """Test getting a non-existent server.""" + mock_response = Mock() + mock_response.status_code = 404 + mock_response.text = "Server not found" + + with patch("app.cli.requests.get", return_value=mock_response): + result = runner.invoke(app, ["get", "999"]) + + assert result.exit_code == 0 + assert "Error:" in result.stdout + +def test_update_server_success(mock_server_data): + """Test updating a server.""" + updated_data = mock_server_data.copy() + updated_data["state"] = "offline" + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = updated_data + + with patch("app.cli.requests.put", return_value=mock_response) as mock_put: + result = runner.invoke(app, ["update", "1", "test-srv-01", "192.168.1.50", "offline", + "us-east-1", "8", "32"]) + + assert result.exit_code == 0 + assert "Updated:" in result.stdout + assert "'state': 'offline'" in result.stdout + + # Verify call URL and payload + args, kwargs = mock_put.call_args + assert "servers/1" in args[0] + assert kwargs['json']['state'] == "offline" + +def test_delete_server_success(): + """Test deleting a server.""" + mock_response = Mock() + mock_response.status_code = 204 + + with patch("app.cli.requests.delete", return_value=mock_response) as mock_del: + result = runner.invoke(app, ["delete", "1"]) + + assert result.exit_code == 0 + assert "Server 1 deleted." in result.stdout + + args, _ = mock_del.call_args + assert "servers/1" in args[0] \ No newline at end of file