From 2ff7ab85ed256e7019d19019839b1c3612c448e4 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 21 Mar 2026 21:00:38 +1100 Subject: [PATCH] Add tests --- .github/workflows/build-and-deploy.yml | 25 ++++ links/file_views.py | 4 +- links/serializers.py | 4 +- links/tasks.py | 4 + pyproject.toml | 14 +++ tests/__init__.py | 0 tests/conftest.py | 49 ++++++++ tests/test_files_api.py | 151 +++++++++++++++++++++++++ tests/test_links_api.py | 65 +++++++++++ tests/test_pages_api.py | 61 ++++++++++ tests/test_posts_api.py | 83 ++++++++++++++ uv.lock | 12 ++ 12 files changed, 467 insertions(+), 5 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_files_api.py create mode 100644 tests/test_links_api.py create mode 100644 tests/test_pages_api.py create mode 100644 tests/test_posts_api.py diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index ed9d5e3..549f4f8 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -10,8 +10,33 @@ env: K8S_NAMESPACE: apps jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies (including dev) + run: uv sync --dev + + - name: Run tests + env: + DJANGO_SETTINGS_MODULE: core.settings + FILE_UPLOADS_FOLDER: /tmp/test-uploads + run: uv run pytest tests/ -v --tb=short + build-and-deploy: runs-on: ubuntu-latest + needs: test permissions: contents: read packages: write diff --git a/links/file_views.py b/links/file_views.py index 5b8064e..99d2a9a 100644 --- a/links/file_views.py +++ b/links/file_views.py @@ -14,7 +14,7 @@ from django.views import View from rest_framework import viewsets, status from rest_framework.decorators import action -from rest_framework.parsers import MultiPartParser, FormParser +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from rest_framework.response import Response from .models import FileUpload @@ -149,7 +149,7 @@ class PublicFileView(View): class FileUploadViewSet(viewsets.ModelViewSet): queryset = FileUpload.objects.all() serializer_class = FileUploadSerializer - parser_classes = [MultiPartParser, FormParser] + parser_classes = [MultiPartParser, FormParser, JSONParser] http_method_names = ['get', 'post', 'delete', 'head', 'options'] def create(self, request, *args, **kwargs): diff --git a/links/serializers.py b/links/serializers.py index 4e5d751..b41ded0 100644 --- a/links/serializers.py +++ b/links/serializers.py @@ -107,9 +107,7 @@ class FileUploadSerializer(serializers.ModelSerializer): return obj.formatted_size() def get_public_url(self, obj): - if obj.is_public and obj.public_token: - return f'/public/files/{obj.public_token}/' - return None + return obj.public_url def get_is_expired(self, obj): return obj.is_expired diff --git a/links/tasks.py b/links/tasks.py index 8ba1ea5..2b23f63 100644 --- a/links/tasks.py +++ b/links/tasks.py @@ -252,6 +252,10 @@ def process_page(page_id, retry_count=0): except Exception as exc: logger.error(f"Failed to process page {page_id}: {exc}") + try: + page = Page.objects.get(id=page_id) + except Page.DoesNotExist: + return page.retry_count += 1 page.last_retry_at = timezone.now() diff --git a/pyproject.toml b/pyproject.toml index 9133546..f4ec4d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,21 @@ line-length = 100 target-version = ["py312"] include = '\.pyi?$' +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "core.settings" +pythonpath = ["."] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] + [tool.isort] profile = "black" multi_line_output = 3 line_length = 100 + +[dependency-groups] +dev = [ + "pytest>=8.3.4", + "pytest-django>=4.10.0", +] 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..23b6c19 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,49 @@ +import io +import os +import tempfile + +import pytest +from django.test import Client +from rest_framework.test import APIClient + + +@pytest.fixture +def client(): + return Client() + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture +def png_file(): + """Minimal valid 1×1 PNG.""" + data = ( + b"\x89PNG\r\n\x1a\n" # signature + b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" + b"\x08\x02\x00\x00\x00\x90wS\xde" # 1x1 RGB + b"\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18\xd8N" + b"\x00\x00\x00\x00IEND\xaeB`\x82" + ) + return io.BytesIO(data) + + +@pytest.fixture +def pdf_file(): + """Minimal valid PDF.""" + data = ( + b"%PDF-1.4\n1 0 obj<>endobj\n" + b"2 0 obj<>endobj\n" + b"3 0 obj<>endobj\n" + b"xref\n0 4\ntrailer<>\nstartxref\n0\n%%EOF" + ) + return io.BytesIO(data) + + +@pytest.fixture(autouse=True) +def use_tmp_upload_dir(settings, tmp_path): + """Redirect FILE_UPLOADS_FOLDER to a temp dir for each test.""" + settings.FILE_UPLOADS_FOLDER = str(tmp_path) + yield diff --git a/tests/test_files_api.py b/tests/test_files_api.py new file mode 100644 index 0000000..f7870da --- /dev/null +++ b/tests/test_files_api.py @@ -0,0 +1,151 @@ +""" +Integration tests for the Files REST API (/api/files) and download endpoint. +""" + +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile + + +def _png(): + data = ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" + b"\x08\x02\x00\x00\x00\x90wS\xde" + b"\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18\xd8N" + b"\x00\x00\x00\x00IEND\xaeB`\x82" + ) + return SimpleUploadedFile("test.png", data, content_type="image/png") + + +def _pdf(): + data = ( + b"%PDF-1.4\n1 0 obj<>endobj\n" + b"2 0 obj<>endobj\n" + b"3 0 obj<>endobj\n" + b"xref\n0 4\ntrailer<>\nstartxref\n0\n%%EOF" + ) + return SimpleUploadedFile("document.pdf", data, content_type="application/pdf") + + +@pytest.mark.django_db +class TestFilesAPI: + BASE = "/api/files" + + def test_list_empty(self, api_client): + r = api_client.get(f"{self.BASE}") + assert r.status_code == 200 + assert r.json()["count"] == 0 + + def test_upload_single_file(self, api_client): + r = api_client.post(f"{self.BASE}", {"files": _png()}, format="multipart") + assert r.status_code == 201 + results = r.json() + assert len(results) == 1 + assert results[0]["name"] == "test.png" + assert results[0]["mime_type"] == "image/png" + assert results[0]["is_public"] is False + assert "id" in results[0] + + def test_upload_multiple_files(self, api_client): + r = api_client.post( + f"{self.BASE}", + {"files": [_png(), _pdf()]}, + format="multipart", + ) + assert r.status_code == 201 + assert len(r.json()) == 2 + + def test_list_after_upload(self, api_client): + api_client.post(f"{self.BASE}", {"files": _png()}, format="multipart") + api_client.post(f"{self.BASE}", {"files": _pdf()}, format="multipart") + r = api_client.get(f"{self.BASE}") + assert r.json()["count"] == 2 + + def test_get_file_detail(self, api_client): + pk = api_client.post( + f"{self.BASE}", {"files": _png()}, format="multipart" + ).json()[0]["id"] + r = api_client.get(f"{self.BASE}/{pk}") + assert r.status_code == 200 + assert r.json()["name"] == "test.png" + + def test_upload_no_files_returns_400(self, api_client): + r = api_client.post(f"{self.BASE}", {}, format="multipart") + assert r.status_code == 400 + + def test_delete_file(self, api_client): + pk = api_client.post( + f"{self.BASE}", {"files": _png()}, format="multipart" + ).json()[0]["id"] + r = api_client.delete(f"{self.BASE}/{pk}") + assert r.status_code == 204 + assert api_client.get(f"{self.BASE}/{pk}").status_code == 404 + + def test_download_url_works(self, client, api_client): + uploaded = api_client.post( + f"{self.BASE}", {"files": _png()}, format="multipart" + ).json()[0] + pk, name = uploaded["id"], uploaded["name"] + r = client.get(f"/ui/files/{pk}-{name}") + assert r.status_code == 200 + assert r["Content-Type"] == "image/png" + + def test_download_increments_count(self, client, api_client): + uploaded = api_client.post( + f"{self.BASE}", {"files": _png()}, format="multipart" + ).json()[0] + pk, name = uploaded["id"], uploaded["name"] + client.get(f"/ui/files/{pk}-{name}") + client.get(f"/ui/files/{pk}-{name}") + detail = api_client.get(f"{self.BASE}/{pk}").json() + assert detail["download_count"] == 2 + + def test_toggle_public(self, api_client): + pk = api_client.post( + f"{self.BASE}", {"files": _png()}, format="multipart" + ).json()[0]["id"] + # Make public + r = api_client.post(f"{self.BASE}/{pk}/toggle-public") + assert r.status_code == 200 + assert r.json()["is_public"] is True + assert r.json()["public_url"] is not None + # Make private again + r2 = api_client.post(f"{self.BASE}/{pk}/toggle-public") + assert r2.json()["is_public"] is False + assert r2.json()["public_url"] is None + + def test_public_file_accessible(self, client, api_client): + pk = api_client.post( + f"{self.BASE}", {"files": _png()}, format="multipart" + ).json()[0]["id"] + api_client.post(f"{self.BASE}/{pk}/toggle-public") + detail = api_client.get(f"{self.BASE}/{pk}").json() + r = client.get(detail["public_url"]) + assert r.status_code == 200 + + def test_private_file_not_accessible_via_public_url(self, client, api_client): + pk = api_client.post( + f"{self.BASE}", {"files": _png()}, format="multipart" + ).json()[0]["id"] + name = "test.png" + r = client.get(f"/public/files/{pk}-{name}") + assert r.status_code == 404 + + def test_set_expiry(self, api_client): + pk = api_client.post( + f"{self.BASE}", {"files": _png()}, format="multipart" + ).json()[0]["id"] + r = api_client.post( + f"{self.BASE}/{pk}/set-expiry", + {"expires_at": "2099-12-31T00:00:00Z"}, + format="json", + ) + assert r.status_code == 200 + assert r.json()["is_expired"] is False + + def test_formatted_size_present(self, api_client): + uploaded = api_client.post( + f"{self.BASE}", {"files": _png()}, format="multipart" + ).json()[0] + assert "formatted_size" in uploaded + assert "B" in uploaded["formatted_size"] diff --git a/tests/test_links_api.py b/tests/test_links_api.py new file mode 100644 index 0000000..fcf3089 --- /dev/null +++ b/tests/test_links_api.py @@ -0,0 +1,65 @@ +""" +Integration tests for the Links feature. +Links are managed via Django template views, tested through the Django test client. +""" + +import pytest + +from links.models import Link + + +@pytest.mark.django_db +class TestLinksUI: + def test_list_page_returns_200(self, client): + response = client.get("/", follow=True) + assert response.status_code == 200 + + def test_create_link(self, client): + response = client.post( + "/create/", + { + "alias": "testlink", + "original_url": "https://example.com", + "link_type": Link.LinkType.LINK, + "description": "", + "text": "", + }, + follow=True, + ) + assert response.status_code == 200 + assert Link.objects.filter(alias="testlink").exists() + + def test_redirect_follows_alias(self, client): + Link.objects.create(alias="gh", original_url="https://github.com") + response = client.get("/gh/") + assert response.status_code == 302 + assert response["Location"] == "https://github.com" + + def test_redirect_unknown_alias_goes_to_create(self, client): + response = client.get("/definitely-not-a-real-alias/") + assert response.status_code == 302 + assert "create" in response["Location"] + + def test_delete_link(self, client): + link = Link.objects.create(alias="del-me", original_url="https://example.com") + response = client.post(f"/delete/{link.pk}/") + assert response.status_code in (200, 302) + assert not Link.objects.filter(pk=link.pk).exists() + + def test_link_with_template_url(self, client): + Link.objects.create( + alias="gosearch", + original_url="https://google.com/search?q={query,default=hello}", + ) + response = client.get("/gosearch/") + assert response.status_code == 302 + assert "q=hello" in response["Location"] + + def test_link_with_template_param_override(self, client): + Link.objects.create( + alias="wiki", + original_url="https://en.wikipedia.org/wiki/{topic,default=Python}", + ) + response = client.get("/wiki/Django/") + assert response.status_code == 302 + assert "Django" in response["Location"] diff --git a/tests/test_pages_api.py b/tests/test_pages_api.py new file mode 100644 index 0000000..3183bde --- /dev/null +++ b/tests/test_pages_api.py @@ -0,0 +1,61 @@ +""" +Integration tests for the Pages REST API (/api/pages). +""" + +import pytest + + +@pytest.mark.django_db +class TestPagesAPI: + BASE = "/api/pages" + + def test_list_empty(self, api_client): + r = api_client.get(f"{self.BASE}") + assert r.status_code == 200 + assert r.json()["count"] == 0 + + def test_create_page(self, api_client): + r = api_client.post(f"{self.BASE}", {"url": "https://example.com"}, format="json") + assert r.status_code == 201 + data = r.json() + assert data["url"] == "https://example.com" + assert "id" in data + + def test_list_after_create(self, api_client): + api_client.post(f"{self.BASE}", {"url": "https://example.com"}, format="json") + api_client.post(f"{self.BASE}", {"url": "https://github.com"}, format="json") + r = api_client.get(f"{self.BASE}") + assert r.json()["count"] == 2 + + def test_get_page_detail(self, api_client): + created = api_client.post( + f"{self.BASE}", {"url": "https://example.com"}, format="json" + ).json() + r = api_client.get(f"{self.BASE}/{created['id']}") + assert r.status_code == 200 + assert r.json()["url"] == "https://example.com" + + def test_update_page_title(self, api_client): + created = api_client.post( + f"{self.BASE}", {"url": "https://example.com"}, format="json" + ).json() + r = api_client.patch( + f"{self.BASE}/{created['id']}", + {"title": "Updated Title"}, + format="json", + ) + assert r.status_code == 200 + assert r.json()["title"] == "Updated Title" + + def test_delete_page(self, api_client): + created = api_client.post( + f"{self.BASE}", {"url": "https://example.com"}, format="json" + ).json() + r = api_client.delete(f"{self.BASE}/{created['id']}") + assert r.status_code == 204 + r2 = api_client.get(f"{self.BASE}/{created['id']}") + assert r2.status_code == 404 + + def test_create_page_missing_url_returns_400(self, api_client): + r = api_client.post(f"{self.BASE}", {}, format="json") + assert r.status_code == 400 diff --git a/tests/test_posts_api.py b/tests/test_posts_api.py new file mode 100644 index 0000000..de0911a --- /dev/null +++ b/tests/test_posts_api.py @@ -0,0 +1,83 @@ +""" +Integration tests for the Posts REST API (/api/posts). +""" + +import pytest + + +@pytest.mark.django_db +class TestPostsAPI: + BASE = "/api/posts" + + def test_list_empty(self, api_client): + r = api_client.get(f"{self.BASE}") + assert r.status_code == 200 + assert r.json()["count"] == 0 + + def test_create_post(self, api_client): + r = api_client.post( + f"{self.BASE}", + {"title": "Hello World", "content": "# Hello\n\nThis is a test post."}, + format="json", + ) + assert r.status_code == 201 + data = r.json() + assert data["title"] == "Hello World" + assert "id" in data + + def test_list_after_create(self, api_client): + for i in range(3): + api_client.post( + f"{self.BASE}", + {"title": f"Post {i}", "content": f"Content {i}"}, + format="json", + ) + r = api_client.get(f"{self.BASE}") + assert r.json()["count"] == 3 + + def test_get_post_detail(self, api_client): + created = api_client.post( + f"{self.BASE}", + {"title": "Detail Test", "content": "Some content"}, + format="json", + ).json() + r = api_client.get(f"{self.BASE}/{created['id']}") + assert r.status_code == 200 + assert r.json()["title"] == "Detail Test" + + def test_update_post(self, api_client): + created = api_client.post( + f"{self.BASE}", + {"title": "Original", "content": "Original content"}, + format="json", + ).json() + r = api_client.patch( + f"{self.BASE}/{created['id']}", + {"title": "Updated"}, + format="json", + ) + assert r.status_code == 200 + assert r.json()["title"] == "Updated" + + def test_delete_post(self, api_client): + created = api_client.post( + f"{self.BASE}", + {"title": "Delete Me", "content": "Content"}, + format="json", + ).json() + r = api_client.delete(f"{self.BASE}/{created['id']}") + assert r.status_code == 204 + assert api_client.get(f"{self.BASE}/{created['id']}").status_code == 404 + + def test_create_post_missing_required_fields(self, api_client): + r = api_client.post(f"{self.BASE}", {"title": "No content"}, format="json") + assert r.status_code == 400 + + def test_create_post_with_summary(self, api_client): + r = api_client.post( + f"{self.BASE}", + {"title": "With Summary", "content": "Body", "summary": "Short summary"}, + format="json", + ) + assert r.status_code == 201 + assert r.json()["summary"] == "Short summary" diff --git a/uv.lock b/uv.lock index 9283076..89748cf 100644 --- a/uv.lock +++ b/uv.lock @@ -605,6 +605,12 @@ dev = [ { name = "pytest-django" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-django" }, +] + [package.metadata] requires-dist = [ { name = "apscheduler", specifier = ">=3.10.0,<4.0.0" }, @@ -636,6 +642,12 @@ requires-dist = [ ] provides-extras = ["dev"] +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3.4" }, + { name = "pytest-django", specifier = ">=4.10.0" }, +] + [[package]] name = "markdown" version = "3.7"