diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..7b5a1a5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,53 @@ +name: Run Tests + +on: + push: + branches: [ '*' ] + pull_request: + branches: [ '*' ] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_DB: folio_test + POSTGRES_USER: admin + POSTGRES_PASSWORD: folio-db-pass-123 + ports: + - 5435:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + 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: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Set environment variables + run: | + echo "DB_HOST=localhost" >> $GITHUB_ENV + echo "DB_PORT=5434" >> $GITHUB_ENV + echo "DB_NAME=folio" >> $GITHUB_ENV + echo "DB_USER=admin" >> $GITHUB_ENV + echo "DB_PASSWORD=folio-db-pass-123" >> $GITHUB_ENV + + - name: Run tests + run: | + pytest -v diff --git a/README.md b/README.md index d4c763d..f14b9a9 100644 --- a/README.md +++ b/README.md @@ -26,4 +26,37 @@ Replace `` and `` with your deployment details. ## Proxy for Overture Services SONG and SCORE -This service also acts as a proxy for the Overture services SONG and SCORE, facilitating secure and authenticated access to these services through Agari-Folio's permission system. \ No newline at end of file +This service also acts as a proxy for the Overture services SONG and SCORE, facilitating secure and authenticated access to these services through Agari-Folio's permission system. + +## Development + +### Install or upgrade dependencies: + +(Working in a python virtual environment is recommended) + +``` +pip install -U --upgrade-strategy=eager requirements.txt +``` + +### Running the app + +Configure environment variables for accessing the backing services, e.g. using a .env file or direnv. + +See settings.py for the variables. + +Run a local server with + +``` +python app.py +``` + +### Tests + +Run tests using pytest: + +``` +pytest +``` + +It'll discover the tests in the `test` directory, e.g. modules beginning with `test_`. Reusable fixtures are generally defined in conftest.py. + diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app.py b/app.py index e007913..216433b 100644 --- a/app.py +++ b/app.py @@ -1,9 +1,10 @@ from flask import Flask,request from flask_restx import Api, Resource -from auth import KeycloakAuth, require_auth, extract_user_info, require_permission, user_has_permission +from auth import keycloak_auth, require_auth, extract_user_info, require_permission, user_has_permission from permissions import PERMISSIONS from database import get_db_cursor, test_connection -import os +from routes.templates import template_ns +from settings import OVERTURE_SONG_URL, OVERTURE_SCORE_URL, PORT import json from datetime import datetime, date from decimal import Decimal @@ -24,15 +25,8 @@ def default(self, obj): app = Flask(__name__) app.json_encoder = CustomJSONEncoder -song = os.getenv('OVERTURE_SONG', 'http://song.local') -score = os.getenv('OVERTURE_SCORE', 'http://score.local') - -keycloak_auth = KeycloakAuth( - keycloak_url=os.getenv('KEYCLOAK_URL', 'http://keycloak.local'), - realm=os.getenv('KEYCLOAK_REALM', 'agari'), - client_id=os.getenv('KEYCLOAK_CLIENT_ID', 'dms'), - client_secret=os.getenv('KEYCLOAK_CLIENT_SECRET', 'VDyLEjGR3xDQvoQlrHq5AB6OwbW0Refc') -) +song = OVERTURE_SONG_URL +score = OVERTURE_SCORE_URL app.keycloak_auth = keycloak_auth @@ -46,6 +40,9 @@ def default(self, obj): # Configure Flask-RESTX to use our custom JSON encoder app.config['RESTX_JSON'] = {'cls': CustomJSONEncoder} +# Register template namespace +api.add_namespace(template_ns) + ########################## ### INFO ########################## @@ -1472,5 +1469,4 @@ def post(self, study_id, analysis_id): if __name__ == '__main__': - port = int(os.getenv('PORT', 8000)) - app.run(debug=True, host='0.0.0.0', port=port) \ No newline at end of file + app.run(debug=True, host='0.0.0.0', port=PORT) diff --git a/auth.py b/auth.py index a53e1a2..d068c8e 100644 --- a/auth.py +++ b/auth.py @@ -4,6 +4,7 @@ from flask import request, jsonify, current_app from jwt.exceptions import InvalidTokenError, ExpiredSignatureError from permissions import PERMISSIONS +from settings import KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET class KeycloakAuth: def __init__(self, keycloak_url, realm, client_id, client_secret): @@ -776,4 +777,12 @@ def wrapper(*args, **kwargs): return {'error': 'Permission denied', 'details': details}, 403 return f(*args, **kwargs) return wrapper - return decorator \ No newline at end of file + return decorator + + +keycloak_auth = KeycloakAuth( + keycloak_url=KEYCLOAK_URL, + realm=KEYCLOAK_REALM, + client_id=KEYCLOAK_CLIENT_ID, + client_secret=KEYCLOAK_CLIENT_SECRET +) diff --git a/database.py b/database.py index e127b55..c5dfd8e 100644 --- a/database.py +++ b/database.py @@ -1,16 +1,16 @@ -import os import psycopg2 from psycopg2.extras import RealDictCursor from contextlib import contextmanager import logging +from settings import DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD # Database configuration DB_CONFIG = { - 'host': os.getenv('DB_HOST', 'localhost'), - 'port': os.getenv('DB_PORT', '5434'), - 'database': os.getenv('DB_NAME', 'folio'), - 'user': os.getenv('DB_USER', 'admin'), - 'password': os.getenv('DB_PASSWORD', 'folio-db-pass-123') + 'host': DB_HOST, + 'port': DB_PORT, + 'database': DB_NAME, + 'user': DB_USER, + 'password': DB_PASSWORD } # Configure logging diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..6fa1efc --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,16 @@ +version: '3.8' + +services: + postgres: + image: postgres:15 + environment: + POSTGRES_DB: folio_test + POSTGRES_USER: admin + POSTGRES_PASSWORD: folio-db-pass-123 + ports: + - "5435:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U admin -d folio_test"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/permissions.py b/permissions.py index 4c6ad84..8e28b30 100644 --- a/permissions.py +++ b/permissions.py @@ -21,6 +21,12 @@ "delete_pathogen": ["system-admin"], "view_pathogens": [], + # Template management + "create_template": ["system-admin"], + "edit_template": ["system-admin"], + "delete_template": ["system-admin"], + "view_templates": [], + # Project management "create_project": ["system-admin", "agari-org-owner", "agari-org-admin"], "edit_projects": ["system-admin", "agari-org-owner", "agari-org-admin"], diff --git a/requirements.txt b/requirements.txt index 0f44fc3..d2f9b0c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,6 @@ PyJWT==2.8.0 cryptography==45.0.7 requests==2.32.5 psycopg2-binary==2.9.7 -python-dotenv==1.0.0 \ No newline at end of file +python-dotenv==1.0.0 +minio==7.2.0 +pytest==7.4.3 diff --git a/routes/templates.py b/routes/templates.py new file mode 100644 index 0000000..264b1c2 --- /dev/null +++ b/routes/templates.py @@ -0,0 +1,379 @@ +""" +Templates module for managing spreadsheet template files. +Templates help users prepare TSV data for genomic analysis. +""" + +from flask import request, send_file, Response +from flask_restx import Namespace, Resource +from auth import require_auth, extract_user_info, require_permission, keycloak_auth +from database import get_db_cursor +from minio import Minio +from minio.error import S3Error +from settings import ( + MINIO_ENDPOINT_CLEAN, + MINIO_ACCESS_KEY, + MINIO_SECRET_KEY, + MINIO_SECURED, + MINIO_BUCKET, +) +import io +import uuid +from logging import getLogger + +logger = getLogger(__name__) + + +# Initialize MinIO client +def get_minio_client(): + """Get configured MinIO client instance""" + return Minio( + MINIO_ENDPOINT_CLEAN, + access_key=MINIO_ACCESS_KEY, + secret_key=MINIO_SECRET_KEY, + secure=MINIO_SECURED + ) + + +# Create namespace +template_ns = Namespace("templates", description="Template management endpoints") + + +@template_ns.route("/") +class TemplateList(Resource): + + @template_ns.doc("list_templates") + def get(self): + """List all templates (public access) + """ + try: + + with get_db_cursor() as cursor: + query = """ + SELECT + t.id, + t.pathogen_id, + t.schema_version, + t.minio_object_id, + t.filename, + t.created_at, + t.updated_at, + p.name as pathogen_name + FROM templates t + LEFT JOIN pathogens p ON t.pathogen_id = p.id + WHERE t.deleted_at IS NULL + ORDER BY t.created_at DESC + """ + cursor.execute(query) + templates = cursor.fetchall() + + return {"templates": templates}, 200 + + except Exception as e: + return {"error": f"Failed to list templates: {str(e)}"}, 500 + + @require_auth(keycloak_auth) + @require_permission('create_template', resource_type='template') + @template_ns.doc("create_template") + def put(self): + """Create or update a template (requires authentication) + + Multipart form data: + - pathogen_id: UUID of the pathogen + - schema_version: Integer version number + - file: Template file (spreadsheet) + """ + try: + # Validate inputs + pathogen_id = request.form.get("pathogen_id") + schema_version = request.form.get("schema_version") + if not pathogen_id or not schema_version: + return {"error": "pathogen_id and schema_version are required"}, 400 + + try: + schema_version = int(schema_version) + except ValueError: + return {"error": "schema_version must be an integer"}, 400 + + if "file" not in request.files: + return {"error": "No file provided"}, 400 + + file = request.files["file"] + if file.filename == "": + return {"error": "No file selected"}, 400 + + file_data = file.read() + file_size = len(file_data) + filename = file.filename + + # Generate MinIO object ID + minio_object_id = f"templates/{uuid.uuid4()}/{filename}" + + minio_client = get_minio_client() + + if not minio_client.bucket_exists(MINIO_BUCKET): + logger.info(f"Creating bucket: {MINIO_BUCKET}") + minio_client.make_bucket(MINIO_BUCKET) + + logger.info(f"Uploading template {filename} to MinIO: {minio_object_id}") + minio_client.put_object( + MINIO_BUCKET, + minio_object_id, + io.BytesIO(file_data), + file_size, + content_type=file.content_type or "application/octet-stream", + ) + + # Check if template already exists for this pathogen and version + with get_db_cursor() as cursor: + cursor.execute( + """ + SELECT id, minio_object_id FROM templates + WHERE pathogen_id = %s AND schema_version = %s AND deleted_at IS NULL + """, + (pathogen_id, schema_version), + ) + + existing = cursor.fetchone() + + if existing: + # Update existing template + template_id = existing["id"] + old_minio_object_id = existing["minio_object_id"] + + cursor.execute( + """ + UPDATE templates + SET minio_object_id = %s, filename = %s, updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + (minio_object_id, filename, template_id), + ) + + # Delete old file from MinIO + try: + minio_client.remove_object( + MINIO_BUCKET, old_minio_object_id + ) + except S3Error: + pass # Ignore if old file doesn't exist + + action = "updated" + else: + # Create new template + cursor.execute( + """ + INSERT INTO templates (pathogen_id, schema_version, minio_object_id, filename) + VALUES (%s, %s, %s, %s) + RETURNING id + """, + (pathogen_id, schema_version, minio_object_id, filename), + ) + template_id = cursor.fetchone()["id"] + action = "created" + + # Fetch the template details + cursor.execute( + """ + SELECT + t.id, + t.pathogen_id, + t.schema_version, + t.minio_object_id, + t.filename, + t.created_at, + t.updated_at, + p.name as pathogen_name + FROM templates t + LEFT JOIN pathogens p ON t.pathogen_id = p.id + WHERE t.id = %s + """, + (template_id,), + ) + + row = cursor.fetchone() + row["download_url"] = f"/templates/{row['id']}/download" + return { + "message": f"Template {action} successfully", + "template": row, + }, 200 + + except Exception as e: + logger.exception("Error creating/updating template") + return { + "error": f"Failed to create/update template: {e.__class__.__name__}: {str(e)}" + }, 500 + + +@template_ns.route("/") +class TemplateDetail(Resource): + + @require_auth(keycloak_auth) + @require_permission('delete_template', resource_type='template') + @template_ns.doc("delete_template") + def delete(self, template_id): + """Delete a template by ID (system-admin only) + + Query Parameters: + - hard: true/false (default: false) - If true, permanently delete from database and MinIO + """ + try: + # Check if hard delete is requested + hard_delete = request.args.get('hard', 'false').lower() == 'true' + + with get_db_cursor() as cursor: + # First get the template details + cursor.execute( + """ + SELECT id, filename, minio_object_id, deleted_at + FROM templates + WHERE id = %s + """, + (template_id,) + ) + + template = cursor.fetchone() + + if not template: + return {'error': 'Template not found'}, 404 + + if hard_delete: + # Hard delete - permanently remove from database and MinIO + cursor.execute( + """ + DELETE FROM templates + WHERE id = %s + RETURNING id, filename, minio_object_id + """, + (template_id,) + ) + + deleted_template = cursor.fetchone() + + # Delete file from MinIO + try: + minio_client = get_minio_client() + minio_client.remove_object(MINIO_BUCKET, deleted_template['minio_object_id']) + logger.info(f"Deleted template file from MinIO: {deleted_template['minio_object_id']}") + except S3Error as e: + logger.warning(f"Failed to delete file from MinIO: {str(e)}") + # Continue anyway - database record is deleted + + return { + 'message': f'Template "{deleted_template["filename"]}" permanently deleted', + 'delete_type': 'hard' + }, 200 + else: + # Soft delete - set deleted_at timestamp + if template['deleted_at']: + return {'error': 'Template already deleted'}, 404 + + cursor.execute( + """ + UPDATE templates + SET deleted_at = NOW(), updated_at = NOW() + WHERE id = %s AND deleted_at IS NULL + RETURNING id, filename + """, + (template_id,) + ) + + deleted_template = cursor.fetchone() + + if not deleted_template: + return {'error': 'Template not found or already deleted'}, 404 + + return { + 'message': f'Template "{deleted_template["filename"]}" deleted (soft delete)', + 'delete_type': 'soft' + }, 200 + + except Exception as e: + logger.exception("Error deleting template") + return {'error': f'Failed to delete template: {str(e)}'}, 500 + + +@template_ns.route("//restore") +class TemplateRestore(Resource): + + @require_auth(keycloak_auth) + @require_permission('delete_template', resource_type='template') + @template_ns.doc("restore_template") + def post(self, template_id): + """Restore a soft-deleted template (system-admin only)""" + try: + with get_db_cursor() as cursor: + cursor.execute( + """ + UPDATE templates + SET deleted_at = NULL, updated_at = NOW() + WHERE id = %s AND deleted_at IS NOT NULL + RETURNING id, filename, pathogen_id, schema_version, minio_object_id, updated_at + """, + (template_id,) + ) + + restored_template = cursor.fetchone() + + if not restored_template: + return {'error': 'Template not found or not deleted'}, 404 + + return { + 'message': f'Template "{restored_template["filename"]}" restored successfully', + 'template': restored_template + }, 200 + + except Exception as e: + if 'duplicate key value violates unique constraint' in str(e): + return {'error': 'Cannot restore: A template with this pathogen and schema version already exists'}, 409 + logger.exception("Error restoring template") + return {'error': f'Failed to restore template: {str(e)}'}, 500 + + +@template_ns.route("//download") +class TemplateDownload(Resource): + + @template_ns.doc("download_template") + def get(self, template_id): + """Download a template file (public access)""" + try: + with get_db_cursor() as cursor: + cursor.execute( + """ + SELECT minio_object_id, filename + FROM templates + WHERE id = %s AND deleted_at IS NULL + """, + (template_id,), + ) + + row = cursor.fetchone() + + if not row: + return {"error": "Template not found"}, 404 + + minio_object_id = row["minio_object_id"] + filename = row["filename"] + + # Get file from MinIO + minio_client = get_minio_client() + + try: + response = minio_client.get_object(MINIO_BUCKET, minio_object_id) + file_data = response.read() + response.close() + response.release_conn() + + # Return file as downloadable response + return Response( + file_data, + mimetype="application/octet-stream", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"' + }, + ) + + except S3Error as e: + return {"error": f"Failed to retrieve file from storage: {str(e)}"}, 500 + + except Exception as e: + return {"error": f"Failed to download template: {str(e)}"}, 500 diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..e5ba091 --- /dev/null +++ b/settings.py @@ -0,0 +1,42 @@ +""" +Application settings and configuration. +All environment variables are centralized here. +""" + +import os +from dotenv import load_dotenv + +# Load environment variables from .env file if it exists +load_dotenv() + +# Server configuration +PORT = int(os.getenv('PORT', '8000')) + +# Database configuration +DB_HOST = os.getenv('FOLIO_DB_HOST', os.getenv('DB_HOST', 'localhost')) +DB_PORT = os.getenv('FOLIO_DB_PORT', os.getenv('DB_PORT', '5434')) +DB_NAME = os.getenv('FOLIO_DB_NAME', os.getenv('DB_NAME', 'folio')) +DB_USER = os.getenv('FOLIO_DB_USER', os.getenv('DB_USER', 'admin')) +DB_PASSWORD = os.getenv('FOLIO_DB_PASSWORD', os.getenv('DB_PASSWORD', 'folio-db-pass-123')) + +# Keycloak authentication configuration +KEYCLOAK_URL = os.getenv('KEYCLOAK_URL', 'http://keycloak.local') +KEYCLOAK_HOST = os.getenv('KEYCLOAK_HOST', KEYCLOAK_URL) # Alias for backwards compatibility +KEYCLOAK_REALM = os.getenv('KEYCLOAK_REALM', 'agari') +KEYCLOAK_CLIENT_ID = os.getenv('KEYCLOAK_CLIENT_ID', 'dms') +KEYCLOAK_CLIENT_SECRET = os.getenv('KEYCLOAK_CLIENT_SECRET', 'VDyLEjGR3xDQvoQlrHq5AB6OwbW0Refc') +KEYCLOAK_ISSUER = os.getenv('KEYCLOAK_ISSUER', f'{KEYCLOAK_URL}/realms/{KEYCLOAK_REALM}') + +# Overture stack services configuration +OVERTURE_SONG_URL = os.getenv('OVERTURE_SONG', 'http://song.local') +OVERTURE_SCORE_URL = os.getenv('OVERTURE_SCORE', 'http://score.local') + +# MinIO configuration +MINIO_ENDPOINT = os.getenv('MINIO_ENDPOINT', 'http://minio:9000') +MINIO_ACCESS_KEY = os.getenv('MINIO_ACCESS_KEY', 'admin') +MINIO_SECRET_KEY = os.getenv('MINIO_SECRET_KEY', 'admin123') +MINIO_SECURED = os.getenv('MINIO_SECURED', 'false').lower() == 'true' +MINIO_BUCKET = os.getenv('MINIO_BUCKET', 'folio') + +# Helper to get clean MinIO endpoint (without http:// or https://) +MINIO_ENDPOINT_CLEAN = MINIO_ENDPOINT.replace('http://', '').replace('https://', '') diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..cd1094e --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,19 @@ +import pytest +import settings + +settings.DB_NAME = 'folio_test' +settings.DB_PORT = '5435' +settings.DB_HOST = 'localhost' +settings.DB_USER = 'admin' +settings.DB_PASSWORD = 'folio-db-pass-123' + +# Import app after overriding settings +from app import app + + +@pytest.fixture +def client(): + """Create a test client for the Flask app""" + app.config['TESTING'] = True + with app.test_client() as client: + yield client diff --git a/test/migrations/2025-10-27-add-templates.sql b/test/migrations/2025-10-27-add-templates.sql new file mode 100644 index 0000000..16acda7 --- /dev/null +++ b/test/migrations/2025-10-27-add-templates.sql @@ -0,0 +1,24 @@ +-- Create templates table +CREATE TABLE IF NOT EXISTS templates ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + pathogen_id UUID NOT NULL REFERENCES pathogens(id) ON DELETE CASCADE, + schema_version INTEGER NOT NULL, + minio_object_id VARCHAR(255) NOT NULL, + filename VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE NULL, + UNIQUE(pathogen_id, schema_version) +); + + +CREATE INDEX IF NOT EXISTS idx_templates_pathogen ON templates(pathogen_id); +CREATE INDEX IF NOT EXISTS idx_templates_schema_version ON templates(schema_version); + + +CREATE TRIGGER update_templates_updated_at + BEFORE UPDATE ON templates + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + + +COMMENT ON TABLE templates IS 'Templates table containing spreadsheet template files for data preparation'; diff --git a/test/rest.http b/test/rest.http index d15ab81..b7f5c27 100644 --- a/test/rest.http +++ b/test/rest.http @@ -447,3 +447,80 @@ Content-Type: application/json "redirect_uri": "https://agari.openup.org.za/", "send_email": "false" } + + + +########################## +### TEMPLATES +########################## + +# @name list_templates +GET {{folio}}/templates + +### + +# @name create_template +PUT {{folio}}/templates/ +Authorization: Bearer {{token}} +Content-Type: multipart/form-data; boundary=----WebKitFormBoundary123 + +------WebKitFormBoundary123 +Content-Disposition: form-data; name="pathogen_id" + +{{pathogen_id}} +------WebKitFormBoundary123 +Content-Disposition: form-data; name="schema_version" + +1 +------WebKitFormBoundary123 +Content-Disposition: form-data; name="file"; filename="template_v1.xlsx" +Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet + +< ./test_template.xlsx +------WebKitFormBoundary123-- + +### +@template_id = {{create_template.response.body.template.id}} + +# @name download_template +GET {{folio}}/templates/{{template_id}}/download + +### + +# @name update_template +PUT {{folio}}/templates/ +Authorization: Bearer {{token}} +Content-Type: multipart/form-data; boundary=----WebKitFormBoundary456 + +------WebKitFormBoundary456 +Content-Disposition: form-data; name="pathogen_id" + +{{pathogen_id}} +------WebKitFormBoundary456 +Content-Disposition: form-data; name="schema_version" + +1 +------WebKitFormBoundary456 +Content-Disposition: form-data; name="file"; filename="template_v1_updated.xlsx" +Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet + +< ./test_template.xlsx +------WebKitFormBoundary456-- + +### + +# @name delete_template_soft +DELETE {{folio}}/templates/{{template_id}} +Authorization: Bearer {{token}} + +### + +# @name restore_template +POST {{folio}}/templates/{{template_id}}/restore +Authorization: Bearer {{token}} + +### + +# @name delete_template_hard +DELETE {{folio}}/templates/{{template_id}}?hard=true +Authorization: Bearer {{token}} diff --git a/test/test_health.py b/test/test_health.py new file mode 100644 index 0000000..7e521a0 --- /dev/null +++ b/test/test_health.py @@ -0,0 +1,7 @@ +def test_database_health(client): + """Test that the /info/health/db endpoint returns 200""" + response = client.get('/info/health/db') + + assert response.status_code == 200 + data = response.get_json() + assert data.get('status') == 'healthy' diff --git a/test/test_template.xlsx b/test/test_template.xlsx new file mode 100644 index 0000000..1b337c9 --- /dev/null +++ b/test/test_template.xlsx @@ -0,0 +1 @@ +Sample Template File