Files
Alex_Portfolio/backend/test_portfolio_content.py
StormRunner06106 404e0dc16d Implemented. Sign in, then open Experience or Skills to:
- Add, edit, and delete experience entries and project links.
- Manage skill categories and individual skill labels.
- Edit the skills introduction and “How I work” text.
Changes save directly to Supabase—no code edits or Git push needed. Existing content is preserved.
2026-09-13 10:03:21 -07:00

120 lines
8.1 KiB
Python

import json
import os
from pathlib import Path
from tempfile import TemporaryDirectory
import unittest
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from backend import main, portfolio_content
class PortfolioContentTests(unittest.TestCase):
def setUp(self):
directory = TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.root = Path(directory.name)
(self.root / "experience.json").write_text("[]", encoding="utf-8")
(self.root / "skills.json").write_text(json.dumps({"intro": "Original intro", "principles": ["Keep it simple"], "categories": []}), encoding="utf-8")
for mocked in (patch.object(main, "DATA_DIR", self.root), patch.object(main, "get_supabase", return_value=None),
patch.dict(os.environ, {"JOURNAL_ADMIN_PASSWORD": "test-password", "JOURNAL_TOKEN_SECRET": "test-secret"})):
mocked.start(); self.addCleanup(mocked.stop)
self.client = TestClient(main.app)
token = self.client.post("/api/auth/login", json={"password": "test-password"}).json()["access_token"]
self.headers = {"Authorization": "Bearer " + token}
self.experience = {"company": "Example", "role": "Engineer", "location": "Remote", "start": "2024-06", "end": None,
"summary": "Build and maintain useful applications.", "highlights": ["Delivered a product"], "technologies": ["Python"],
"projects": [{"name": "Project", "href": "https://example.com", "period": "2024"}]}
self.category = {"name": "Engineering", "description": "Tools I use", "accent": "blue", "items": ["Python", "React"]}
def test_experience_crud_is_publicly_readable_and_persistent(self):
created = self.client.post("/api/experience", json=self.experience, headers=self.headers)
self.assertEqual(created.status_code, 201, created.text)
item = created.json()
older = self.client.post("/api/experience", json={**self.experience, "start": "2020-01", "end": "2023-12"}, headers=self.headers).json()
self.assertEqual([entry["id"] for entry in self.client.get("/api/experience").json()], [item["id"], older["id"]])
changed = {**self.experience, "role": "Lead engineer", "technologies": ["Go"], "projects": [], "highlights": []}
response = self.client.put("/api/experience/" + item["id"], json=changed, headers=self.headers)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["id"], item["id"])
saved = json.loads((self.root / "experience.json").read_text())
self.assertEqual(saved[0]["role"], "Lead engineer")
for entry in [item, older]:
self.assertEqual(self.client.delete("/api/experience/" + entry["id"], headers=self.headers).status_code, 200)
self.assertEqual(self.client.get("/api/experience").json(), [])
def test_skills_categories_labels_and_overview_crud(self):
response = self.client.post("/api/skills/categories", json=self.category, headers=self.headers)
self.assertEqual(response.status_code, 201, response.text)
item = response.json()
changed = {**self.category, "name": "Updated category", "items": ["TypeScript"]}
response = self.client.put("/api/skills/categories/" + item["id"], json=changed, headers=self.headers)
self.assertEqual(response.status_code, 200)
overview = {"intro": "A new introduction", "principles": ["One principle", "Another principle"]}
self.assertEqual(self.client.patch("/api/skills", json=overview, headers=self.headers).status_code, 200)
content = self.client.get("/api/skills").json()
self.assertEqual(content["intro"], overview["intro"])
self.assertEqual(content["categories"][0]["items"], ["TypeScript"])
self.assertEqual(content["categories"][0]["id"], item["id"])
self.assertEqual(self.client.delete("/api/skills/categories/" + item["id"], headers=self.headers).status_code, 200)
self.client.patch("/api/skills", json={**overview, "principles": []}, headers=self.headers)
content = self.client.get("/api/skills").json()
self.assertEqual(content["categories"], [])
self.assertEqual(content["principles"], [])
def test_all_mutations_require_admin(self):
for method, path, body in [
("post", "/api/experience", self.experience), ("put", "/api/experience/missing", self.experience),
("delete", "/api/experience/missing", None), ("post", "/api/skills/categories", self.category),
("put", "/api/skills/categories/missing", self.category), ("delete", "/api/skills/categories/missing", None),
("patch", "/api/skills", {"intro": "Intro", "principles": []}),
]:
for headers in ({}, {"Authorization": "Bearer 1.invalid"}):
response = self.client.request(method, path, json=body, headers=headers)
self.assertEqual(response.status_code, 401, (method, path))
self.assertEqual(self.client.get("/api/experience").status_code, 200)
self.assertEqual(self.client.get("/api/skills").status_code, 200)
def test_invalid_dates_links_fields_and_missing_records(self):
for changes in ({"start": "2024-13"}, {"end": "2020-01"}, {"role": " "},
{"projects": [{"name": "Unsafe", "href": "javascript:alert(1)"}]}, {"id": "client-selected"}):
self.assertEqual(self.client.post("/api/experience", json={**self.experience, **changes}, headers=self.headers).status_code, 422)
self.assertEqual(self.client.post("/api/skills/categories", json={**self.category, "accent": "invalid"}, headers=self.headers).status_code, 422)
for path, body in [("/api/experience/missing", self.experience), ("/api/skills/categories/missing", self.category)]:
self.assertEqual(self.client.put(path, json=body, headers=self.headers).status_code, 404)
self.assertEqual(self.client.delete(path, headers=self.headers).status_code, 404)
def test_supabase_writes_use_versions_and_fail_without_silent_fallback(self):
client = MagicMock()
table = client.table.return_value
read = table.select.return_value.eq.return_value.limit.return_value.retry.return_value.execute
read.return_value.data = [{"payload": [], "version": 7}]
write = table.update.return_value.eq.return_value.eq.return_value.execute
write.return_value.data = [{"key": "experience"}]
with patch.object(main, "get_supabase", return_value=client):
response = self.client.post("/api/experience", json=self.experience, headers=self.headers)
self.assertEqual(response.status_code, 201)
written = table.update.call_args.args[0]
self.assertEqual(written["version"], 8)
self.assertEqual(written["payload"][0]["role"], "Engineer")
table.update.return_value.eq.assert_called_with("key", "experience")
table.update.return_value.eq.return_value.eq.assert_called_with("version", 7)
write.return_value.data = []
self.assertEqual(self.client.post("/api/experience", json=self.experience, headers=self.headers).status_code, 409)
write.side_effect = RuntimeError("offline")
self.assertEqual(self.client.post("/api/experience", json=self.experience, headers=self.headers).status_code, 502)
read.return_value.data = []
self.assertEqual(self.client.get("/api/experience").status_code, 503)
self.assertEqual(json.loads((self.root / "experience.json").read_text()), [])
def test_local_category_ids_remain_stable_when_deleting_previous_category(self):
content = {"intro": "Intro", "principles": [], "categories": [{**self.category, "name": name} for name in ["First", "Second"]]}
(self.root / "skills.json").write_text(json.dumps(content))
categories = self.client.get("/api/skills").json()["categories"]
self.client.delete("/api/skills/categories/" + categories[0]["id"], headers=self.headers)
self.assertEqual(self.client.get("/api/skills").json()["categories"][0]["id"], categories[1]["id"])
if __name__ == "__main__":
unittest.main()