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 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 RUN apt-get update && apt-get install -y ffmpeg
COPY requirements.txt ./ COPY requirements.txt ./
RUN pip install --no-cache-dir -r 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 . . COPY . .
# No `CMD`, because there are plenty of potential test commands, no single sensible default.
CMD [ "python", "./main.py" ]

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): def do_GET(self):
self.send_response(HTTPStatus.INTERNAL_SERVER_ERROR) 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-type", 'application/json')
self.send_header('Content-Length', len(content)) self.send_header('Content-Length', len(content))
self.end_headers() self.end_headers()

View File

@ -4,7 +4,7 @@ import os
import socketserver import socketserver
from functools import partial from functools import partial
from handler import Handler from src.handler import Handler
from multiprocessing import Pool from multiprocessing import Pool
PORT = int(os.environ.get('PORT', '8000')) 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"