mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
Add tests
This commit is contained in:
@@ -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
|
||||
|
||||
+2
-2
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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<</Type/Catalog/Pages 2 0 R>>endobj\n"
|
||||
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
|
||||
b"3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>endobj\n"
|
||||
b"xref\n0 4\ntrailer<</Size 4/Root 1 0 R>>\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
|
||||
@@ -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<</Type/Catalog/Pages 2 0 R>>endobj\n"
|
||||
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
|
||||
b"3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>endobj\n"
|
||||
b"xref\n0 4\ntrailer<</Size 4/Root 1 0 R>>\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"]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user