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:
parent
fb1b485849
commit
0a2da8eb51
31
Dockerfile
31
Dockerfile
@ -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
46
compose.yaml
Normal 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
2
dev-requirements.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
pytest==8.3.4
|
||||||
|
requests==2.28.1
|
1
download_dir/README.md
Normal file
1
download_dir/README.md
Normal 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!
|
35
integ-tests/test_end-to-end.py
Normal file
35
integ-tests/test_end-to-end.py
Normal 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
|
@ -1 +1 @@
|
|||||||
yt-dlp
|
yt-dlp==2024.10.22
|
||||||
|
0
src/__init__.py
Normal file
0
src/__init__.py
Normal 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()
|
@ -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
20
src/test_handler.py
Normal 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"
|
Loading…
x
Reference in New Issue
Block a user