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.
This commit is contained in:
StormRunner06106
2026-09-13 10:03:21 -07:00
parent 6e3779e500
commit 404e0dc16d
25 changed files with 1337 additions and 82 deletions
+119
View File
@@ -0,0 +1,119 @@
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()