Add tests

This uses the `docker compose run --rm` approach suggested by Dudo
[here](https://github.com/orgs/gitops-ci-cd/discussions/1). I'm...still
learning my around it. It has some learning curve - especially regarding
running integration tests (the current setup _keeps_ the app running
even after tests have terminated, which is probably not as-desired), but
I suspect it'll become second-nature pretty quickly and will have
outsized benefits when working in a team with heterogeneous workstation
setups.
This commit is contained in:
Jack Jackson 2025-02-02 20:05:42 -08:00
parent fb1b485849
commit 0a2da8eb51
10 changed files with 135 additions and 6 deletions

View File

@ -1,12 +1,37 @@
FROM python:3.13
# syntax=docker.io/docker/dockerfile:1.7-labs
FROM python:3.13 AS deps
WORKDIR /usr/src/app
COPY dev-requirements.txt ./
RUN pip install --no-cache-dir -r dev-requirements.txt
RUN rm dev-requirements.txt
#=======================================================
FROM deps AS app
WORKDIR /usr/src/app
RUN apt-get update && apt-get install -y ffmpeg
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
RUN rm requirements.txt
COPY --exclude=test_*.py src ./src
# Thanks to https://stackoverflow.com/questions/64776990/python-docker-no-module-found
ENV PYTHONPATH /usr/src/app
EXPOSE 8000
ENTRYPOINT ["python"]
CMD ["src/main.py"]
#=======================================================
FROM deps AS test
COPY dev-requirements.txt ./
RUN pip install --no-cache-dir -r dev-requirements.txt
COPY . .
CMD [ "python", "./main.py" ]
# No `CMD`, because there are plenty of potential test commands, no single sensible default.

46
compose.yaml Normal file
View File

@ -0,0 +1,46 @@
services:
base: &base
tty: true
stdin_open: true
# Necessary because compose does not permit a service to lack a build
build:
context: .
target: deps
entrypoint: python
volumes:
- .:/usr/src/app:delegated
app:
build:
context: .
target: app
environment:
- DOWNLOAD_DIR=/download
ports:
- "8000:8000"
volumes:
- ./download_dir:/download
test-base: &test-base
<<: *base
build:
context: .
target: test
test:
<<: *test-base
command: ["-m", "pytest", "--ignore=integ-tests", "."]
test-with-output:
<<: *test-base
command: ["-m", "pytest", "-rP", "--ignore=integ-tests", "."]
test-integ:
<<: *test-base
depends_on:
# TODO - should really have a healthcheck here to avoid race-conditions. "Up" doesn't actually mean "Up and accepting requests"
- app
command: ["-m", "pytest", "integ-tests" ]
volumes:
- ./download_dir:/download
- .:/usr/src/app:delegated # Have to re-specify this because the specification above overrides, rather than appending

2
dev-requirements.txt Normal file
View File

@ -0,0 +1,2 @@
pytest==8.3.4
requests==2.28.1

1
download_dir/README.md Normal file
View File

@ -0,0 +1 @@
A location into which the application can download files when run locally. Particularly useful for sharing with the Integ Test service!

View File

@ -0,0 +1,35 @@
import pathlib
import requests
import time
def test_download():
target_file_name = "A Beginner's Guide to the EICAR Test File [bTThnbwxN5g].m4a"
download_path = pathlib.Path('/download')
url = 'https://www.youtube.com/watch?v=bTThnbwxN5g'
try:
# `app` is injected as a DNS name by the Docker Compose harness
response = requests.post('http://app:8000/download', json={
'url': url
})
assert response.status_code == 202, f"Non-202 response: {response.status_code}. Body: {response.json()}"
time.sleep(2)
for _ in range(5):
if _does_target_file_exist(target_file_name, download_path):
break
else:
time.sleep(3)
else:
assert False, "File not found after 15 seconds"
finally:
for f in download_path.iterdir():
if f.name != 'README.md':
f.unlink()
def _does_target_file_exist(file_name: str, dir: pathlib.Path) -> bool:
for f in dir.iterdir():
if f.name == file_name:
return True
else:
print(f"Found file: {f.name} which does not match")
return False

View File

@ -1 +1 @@
yt-dlp
yt-dlp==2024.10.22

0
src/__init__.py Normal file
View File

View File

@ -69,7 +69,7 @@ class Handler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
self.send_response(HTTPStatus.INTERNAL_SERVER_ERROR)
content = 'ERROR: Only HEAD requests are permitted\n'.encode('utf-8')
content = 'ERROR: Only POST requests are permitted\n'.encode('utf-8')
self.send_header("Content-type", 'application/json')
self.send_header('Content-Length', len(content))
self.end_headers()

View File

@ -4,7 +4,7 @@ import os
import socketserver
from functools import partial
from handler import Handler
from src.handler import Handler
from multiprocessing import Pool
PORT = int(os.environ.get('PORT', '8000'))

20
src/test_handler.py Normal file
View File

@ -0,0 +1,20 @@
import os
import pathlib
import tempfile
from src.handler import download
def test_download():
video_url = "https://www.youtube.com/watch?v=bTThnbwxN5g"
expected_filename = "A Beginner's Guide to the EICAR Test File [bTThnbwxN5g].m4a"
with tempfile.TemporaryDirectory() as tmpdirname:
os.environ['DOWNLOAD_DIR'] = tmpdirname
download(video_url)
download_dir = pathlib.Path(tmpdirname)
passes = False
for contents in download_dir.iterdir():
print(f'DEBUG - {contents=}')
if contents.name == expected_filename:
passes = True
break
assert passes, "Could not find downloaded file"