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
+15 -1
View File
@@ -4,7 +4,7 @@ import re
from functools import lru_cache
import dropbox
from dropbox.exceptions import AuthError, DropboxException
from dropbox.exceptions import ApiError, AuthError, DropboxException
from fastapi import HTTPException
from requests.exceptions import RequestException
@@ -58,3 +58,17 @@ def download(file_id: str, limit: int) -> bytes:
raise HTTPException(503, "Dropbox needs to be reconnected by the site owner.") from exc
except (DropboxException, RequestException) as exc:
raise HTTPException(502, "This file is temporarily unavailable from Dropbox. Please retry.") from exc
def delete(file_id: str) -> None:
try:
get_dropbox().files_delete_v2(file_id)
except AuthError as exc:
raise HTTPException(503, "Dropbox needs to be reconnected by the site owner.") from exc
except ApiError as exc:
# A retry may follow a successful Dropbox delete and failed database cleanup.
if exc.error.is_path_lookup() and exc.error.get_path_lookup().is_not_found():
return
raise HTTPException(502, "Dropbox could not delete this file. Deletion will be retried.") from exc
except (DropboxException, RequestException) as exc:
raise HTTPException(502, "Dropbox could not delete this file. Deletion will be retried.") from exc
+34 -12
View File
@@ -27,7 +27,7 @@ from pydantic import BaseModel, EmailStr, Field
from starlette.concurrency import run_in_threadpool
from httpx import TransportError
from supabase import Client, ClientOptions, PostgrestAPIError, create_client
from backend import dropbox_storage
from backend import dropbox_storage, media_cleanup, portfolio_content
BASE_DIR = Path(__file__).resolve().parent
@@ -56,6 +56,7 @@ app = FastAPI(
title="Alex Herlan Portfolio API",
description="Supabase-ready content and contact delivery for Alex Herlan's portfolio.",
version="1.1.0",
lifespan=media_cleanup.lifespan,
)
origins = [
@@ -94,6 +95,7 @@ class NewPostPayload(BaseModel):
content: dict[str, Any]
banner: ArticleMedia | None = None
attachments: list[ArticleMedia] = Field(default_factory=list, max_length=10)
discarded_uploads: list[str] = Field(default_factory=list, max_length=100, exclude=True)
@lru_cache(maxsize=8)
@@ -260,14 +262,7 @@ def profile() -> dict[str, Any]:
return read_data("profile.json")
@app.get("/api/experience")
def experience() -> list[dict[str, Any]]:
return read_data("experience.json")
@app.get("/api/skills")
def skills() -> dict[str, Any]:
return read_data("skills.json")
portfolio_content.register_routes(app, require_admin)
@app.post("/api/auth/login")
@@ -425,7 +420,15 @@ def validated_article(payload: NewPostPayload, slug: str) -> dict[str, Any]:
if media is None:
return None
stored = uploaded_media(media["url"].rsplit("/", 1)[-1])
if ArticleMedia.model_validate(stored).model_dump() != media:
canonical = ArticleMedia.model_validate(stored).model_dump()
# An editor can remain open while its local files are migrated to Dropbox.
# The upload URL and file details are unchanged; resolve the new storage
# fields from the server without trusting a client-supplied Dropbox ID.
if (media["storage"] is None and media["dropbox_file_id"] is None
and canonical["storage"] == "dropbox" and canonical["dropbox_file_id"]):
media = {**media, "storage": canonical["storage"],
"dropbox_file_id": canonical["dropbox_file_id"]}
if canonical != media:
raise HTTPException(status_code=422, detail="An attachment is invalid. Please upload it again.")
return stored
article["banner"] = canonical_media(article["banner"])
@@ -451,11 +454,13 @@ def save_local_posts(records: list[dict[str, Any]]) -> None:
@app.post("/api/posts", status_code=status.HTTP_201_CREATED)
@media_cleanup.serialized
def create_post(
payload: NewPostPayload, _: None = Depends(require_admin)
) -> dict[str, Any]:
slug = post_slug(payload.title)
article = validated_article(payload, slug)
media_cleanup.enqueue_urls(payload.discarded_uploads)
client = get_supabase()
if client is not None:
@@ -482,6 +487,7 @@ def create_post(
) from exc
if not created.data:
raise HTTPException(status_code=502, detail="Supabase did not return the new article.")
media_cleanup.cleanup_pending()
return created.data[0]
with POSTS_LOCK:
@@ -494,10 +500,12 @@ def create_post(
current_posts.append(article)
save_local_posts(current_posts)
media_cleanup.cleanup_pending()
return article
@app.put("/api/posts/{slug}")
@media_cleanup.serialized
def update_post(
slug: str, payload: NewPostPayload, _: None = Depends(require_admin)
) -> dict[str, Any]:
@@ -505,6 +513,10 @@ def update_post(
raise HTTPException(status_code=404, detail="Post not found")
# Keep published URLs stable when an admin changes the title.
article = validated_article(payload, slug)
previous = post_by_slug(slug)
media_cleanup.enqueue_urls(payload.discarded_uploads)
retained = {m["url"] for m in media_cleanup.media_items(article)}
media_cleanup.enqueue([m for m in media_cleanup.media_items(previous) if m["url"] not in retained])
client = get_supabase()
if client is not None:
try:
@@ -513,6 +525,7 @@ def update_post(
raise HTTPException(status_code=502, detail="The article could not be updated. Please try again.") from exc
if not response.data:
raise HTTPException(status_code=404, detail="Post not found")
media_cleanup.cleanup_pending()
return response.data[0]
with POSTS_LOCK:
@@ -521,14 +534,21 @@ def update_post(
if existing["slug"] == slug:
records[index] = {**existing, **article}
save_local_posts(records)
return records[index]
raise HTTPException(status_code=404, detail="Post not found")
saved = records[index]
break
else:
raise HTTPException(status_code=404, detail="Post not found")
media_cleanup.cleanup_pending()
return saved
@app.delete("/api/posts/{slug}")
@media_cleanup.serialized
def delete_post(slug: str, _: None = Depends(require_admin)) -> dict[str, bool]:
if not re.fullmatch(r"[a-z0-9-]+", slug):
raise HTTPException(status_code=404, detail="Post not found")
previous = post_by_slug(slug)
media_cleanup.enqueue(media_cleanup.media_items(previous))
client = get_supabase()
if client is not None:
try:
@@ -537,6 +557,7 @@ def delete_post(slug: str, _: None = Depends(require_admin)) -> dict[str, bool]:
raise HTTPException(status_code=502, detail="The article could not be deleted. Please try again.") from exc
if not response.data:
raise HTTPException(status_code=404, detail="Post not found")
media_cleanup.cleanup_pending()
return {"deleted": True}
with POSTS_LOCK:
@@ -545,6 +566,7 @@ def delete_post(slug: str, _: None = Depends(require_admin)) -> dict[str, bool]:
if len(remaining) == len(records):
raise HTTPException(status_code=404, detail="Post not found")
save_local_posts(remaining)
media_cleanup.cleanup_pending()
return {"deleted": True}
+144
View File
@@ -0,0 +1,144 @@
"""Durable cleanup of explicitly removed journal media after article writes."""
import asyncio
from contextlib import asynccontextmanager, suppress
from functools import wraps
import json
import inspect
import re
from threading import RLock
from fastapi import HTTPException
from starlette.concurrency import run_in_threadpool
LOCK = RLock()
TABLE = "article_media_deletions"
def serialized(function):
@wraps(function)
def wrapped(*args, **kwargs):
with LOCK:
return function(*args, **kwargs)
wrapped.__signature__ = inspect.signature(function, eval_str=True)
return wrapped
def media_items(article):
return [m for m in [article.get("banner"), *(article.get("attachments") or [])] if m]
def local_queue_path():
from backend import main
return main.DATA_DIR / "media-deletions.json"
def pending():
from backend import main
client = main.get_supabase()
if client is not None:
return main.read_article_query(client.table(TABLE).select("upload_id,metadata").order("created_at")).data
path = local_queue_path()
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else []
def save_local_queue(records):
path = local_queue_path()
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(records), encoding="utf-8")
temporary.replace(path)
def enqueue(media):
"""Persist intent before changing the article; referenced files stay protected."""
from backend import main
if not media:
return
records = {}
for item in media:
upload_id = item["url"].rsplit("/", 1)[-1]
stored = main.uploaded_media(upload_id)
records[upload_id] = {"upload_id": upload_id, "metadata": stored}
client = main.get_supabase()
try:
if client is not None:
result = client.table(TABLE).upsert(list(records.values())).execute()
if not result.data:
raise RuntimeError("Removal queue was not saved")
else:
save_local_queue(list({**{r["upload_id"]: r for r in pending()}, **records}.values()))
except Exception as exc:
raise HTTPException(502, "The file removal could not be scheduled. Please save again.") from exc
def enqueue_urls(urls):
from backend import main
media = []
for url in set(urls):
if not re.fullmatch(r"/api/uploads/[a-f0-9]{32}", url):
raise HTTPException(422, "Invalid file removal reference.")
try:
media.append(main.uploaded_media(url.rsplit("/", 1)[-1]))
except HTTPException as exc:
if exc.status_code != 404:
raise
enqueue(media)
def forget(upload_id):
from backend import main
client = main.get_supabase()
if client is not None:
client.table(TABLE).delete().eq("upload_id", upload_id).execute()
else:
save_local_queue([r for r in pending() if r["upload_id"] != upload_id])
@serialized
def cleanup_pending():
from backend import main, dropbox_storage
try:
records = pending()
if not records:
return
referenced = [m for p in main.article_records(include_content=False) for m in media_items(p)]
active_urls = {m["url"] for m in referenced}
active_ids = {m.get("dropbox_file_id") for m in referenced if m.get("dropbox_file_id")}
client = main.get_supabase()
for record in records:
upload_id, media = record["upload_id"], record["metadata"]
if not re.fullmatch(r"[a-f0-9]{32}", upload_id):
continue
if media["url"] in active_urls or media.get("dropbox_file_id") in active_ids:
continue
try:
if media.get("storage") == "dropbox":
dropbox_storage.delete(media["dropbox_file_id"])
# Remove registry and migration backups only after Dropbox succeeds.
if client is not None:
client.table("article_media").delete().eq("upload_id", upload_id).execute()
for suffix in ("", ".json"):
path = main.UPLOAD_DIR / (upload_id + suffix)
if path.resolve().parent != main.UPLOAD_DIR.resolve():
raise ValueError("Invalid upload cleanup path")
path.unlink(missing_ok=True)
forget(upload_id)
except Exception as exc:
main.logger.warning("Journal file cleanup pending: type=%s", type(exc).__name__)
except Exception as exc:
main.logger.warning("Journal cleanup retry pending: type=%s", type(exc).__name__)
@asynccontextmanager
async def lifespan(app):
async def retry():
while True:
await run_in_threadpool(cleanup_pending)
await asyncio.sleep(60)
task = asyncio.create_task(retry())
try:
yield
finally:
task.cancel()
with suppress(asyncio.CancelledError):
await task
+165
View File
@@ -0,0 +1,165 @@
"""Editable experience and skills, backed by Supabase with local development storage."""
import json
from threading import RLock
from typing import Annotated, Literal
from uuid import uuid4
from fastapi import Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, StringConstraints, model_validator
LOCK = RLock()
TABLE = "portfolio_content"
ShortText = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=100)]
LongText = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=1000)]
Month = Annotated[str, StringConstraints(pattern=r"^[1-9][0-9]{3}-(0[1-9]|1[0-2])$")]
class ContentModel(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
class Project(ContentModel):
name: ShortText
period: str = Field(default="", max_length=100)
href: HttpUrl
class ExperienceInput(ContentModel):
company: ShortText
role: str = Field(min_length=1, max_length=200)
location: str = Field(min_length=1, max_length=200)
start: Month
end: Month | None = None
summary: str = Field(min_length=1, max_length=4000)
highlights: list[LongText] = Field(default_factory=list, max_length=30)
technologies: list[ShortText] = Field(default_factory=list, max_length=60)
projects: list[Project] = Field(default_factory=list, max_length=20)
@model_validator(mode="after")
def ordered_dates(self):
if self.end and self.end < self.start:
raise ValueError("End date must be on or after the start date.")
return self
class CategoryInput(ContentModel):
name: ShortText
description: str = Field(min_length=1, max_length=1000)
accent: Literal["blue", "lavender", "peach", "yellow", "mint"] = "mint"
items: list[ShortText] = Field(default_factory=list, max_length=100)
class SkillsOverview(ContentModel):
intro: str = Field(min_length=1, max_length=2000)
principles: list[LongText] = Field(default_factory=list, max_length=20)
def load_document(key):
from backend import main
client = main.get_supabase()
if client is None:
# Read the file on each edit; don't mutate objects held in the public cache.
path = main.DATA_DIR / f"{key}.json"
return {"payload": json.loads(path.read_text(encoding="utf-8")), "version": 0}
response = main.read_article_query(client.table(TABLE).select("payload,version").eq("key", key).limit(1))
if not response.data:
raise HTTPException(503, "Portfolio content has not been initialized on the server.")
return response.data[0]
def save_document(key, payload, version):
from backend import main
client = main.get_supabase()
if client is None:
path = main.DATA_DIR / f"{key}.json"
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
temporary.replace(path)
main.read_data.cache_clear()
return
try:
response = client.table(TABLE).update({"payload": payload, "version": version + 1}).eq("key", key).eq("version", version).execute()
except Exception as exc:
raise HTTPException(502, "Your changes could not be saved. Please try again.") from exc
if not response.data:
raise HTTPException(409, "This content changed while saving. Please try saving again.")
def experience():
return sorted(load_document("experience")["payload"], key=lambda item: item["start"], reverse=True)
def skills():
content = load_document("skills")["payload"]
# Stable fallback IDs support existing local JSON before running the seeder.
for index, category in enumerate(content["categories"]):
category.setdefault("id", f"category-{index + 1}")
return content
def mutate_item(key, collection, item_id=None, payload=None):
with LOCK:
document = load_document(key)
content = document["payload"]
items = content if collection is None else content[collection]
if collection:
for index, item in enumerate(items):
item.setdefault("id", f"category-{index + 1}")
if item_id is None:
record = {**payload, "id": uuid4().hex}
items.append(record)
else:
index = next((i for i, item in enumerate(items) if item["id"] == item_id), None)
if index is None:
raise HTTPException(404, "This entry no longer exists.")
if payload is None:
items.pop(index)
record = {"deleted": True}
else:
record = {**payload, "id": item_id}
items[index] = record
save_document(key, content, document["version"])
return record
def register_routes(app, require_admin):
@app.get("/api/experience")
def get_experience():
return experience()
@app.post("/api/experience", status_code=201)
def create_experience(payload: ExperienceInput, _: None = Depends(require_admin)):
return mutate_item("experience", None, payload=payload.model_dump(mode="json"))
@app.put("/api/experience/{item_id}")
def update_experience(item_id: str, payload: ExperienceInput, _: None = Depends(require_admin)):
return mutate_item("experience", None, item_id, payload.model_dump(mode="json"))
@app.delete("/api/experience/{item_id}")
def delete_experience(item_id: str, _: None = Depends(require_admin)):
return mutate_item("experience", None, item_id)
@app.get("/api/skills")
def get_skills():
return skills()
@app.patch("/api/skills")
def update_overview(payload: SkillsOverview, _: None = Depends(require_admin)):
with LOCK:
document = load_document("skills")
content = document["payload"]
content.update(payload.model_dump(mode="json"))
save_document("skills", content, document["version"])
return content
@app.post("/api/skills/categories", status_code=201)
def create_category(payload: CategoryInput, _: None = Depends(require_admin)):
return mutate_item("skills", "categories", payload=payload.model_dump(mode="json"))
@app.put("/api/skills/categories/{item_id}")
def update_category(item_id: str, payload: CategoryInput, _: None = Depends(require_admin)):
return mutate_item("skills", "categories", item_id, payload.model_dump(mode="json"))
@app.delete("/api/skills/categories/{item_id}")
def delete_category(item_id: str, _: None = Depends(require_admin)):
return mutate_item("skills", "categories", item_id)
+25
View File
@@ -0,0 +1,25 @@
"""One-time seed: python -m backend.seed_portfolio. Existing content is preserved."""
import json
from dotenv import load_dotenv
from backend import main, portfolio_content
def seed():
client = main.get_supabase()
if client is None:
raise SystemExit("Configure Supabase before seeding portfolio content.")
for key in ("experience", "skills"):
payload = json.loads((main.DATA_DIR / f"{key}.json").read_text(encoding="utf-8"))
if key == "skills":
for index, category in enumerate(payload["categories"]):
category.setdefault("id", f"category-{index + 1}")
# ON CONFLICT DO NOTHING makes rerunning setup safe for admin edits.
client.table(portfolio_content.TABLE).upsert(
{"key": key, "payload": payload, "version": 1}, on_conflict="key", ignore_duplicates=True,
).execute()
print(f"{key}: initialized if missing; existing edits preserved.")
if __name__ == "__main__":
load_dotenv(main.BASE_DIR / ".env")
seed()
+21
View File
@@ -36,3 +36,24 @@ create table if not exists public.article_media (
alter table public.article_media enable row level security;
revoke all on table public.article_media from anon, authenticated;
grant select, insert, update, delete on table public.article_media to service_role;
-- Persist removal intent before article changes so Dropbox outages can be retried.
create table if not exists public.article_media_deletions (
upload_id text primary key check (upload_id ~ '^[a-f0-9]{32}$'),
metadata jsonb not null,
created_at timestamptz not null default now()
);
alter table public.article_media_deletions enable row level security;
revoke all on table public.article_media_deletions from anon, authenticated;
grant select, insert, update, delete on table public.article_media_deletions to service_role;
-- Editable portfolio sections. Version checks prevent concurrent document writes
-- from silently replacing each other's changes.
create table if not exists public.portfolio_content (
key text primary key check (key in ('experience', 'skills')),
payload jsonb not null,
version bigint not null default 1
);
alter table public.portfolio_content enable row level security;
revoke all on table public.portfolio_content from anon, authenticated;
grant select, insert, update, delete on table public.portfolio_content to service_role;
+116 -1
View File
@@ -10,7 +10,8 @@ from fastapi.testclient import TestClient
from PIL import Image
from httpx import ReadTimeout, RemoteProtocolError
from supabase import PostgrestAPIError
from dropbox.exceptions import AuthError
from dropbox.exceptions import ApiError, AuthError
from dropbox.files import DeleteError, LookupError
from backend import main
@@ -126,6 +127,8 @@ class ArticleMediaTests(unittest.TestCase):
table = client.table.return_value
payload = self.article_payload()
table.update.return_value.eq.return_value.execute.return_value.data = [{**payload, "slug": "original-url"}]
table.select.return_value.eq.return_value.limit.return_value.retry.return_value.execute.return_value.data = [{**payload, "slug": "original-url"}]
table.select.return_value.order.return_value.retry.return_value.execute.return_value.data = []
table.delete.return_value.eq.return_value.execute.return_value.data = [{"slug": "original-url"}]
with patch.object(main, "get_supabase", return_value=client):
updated = self.client.put("/api/posts/original-url", json=payload, headers=self.headers)
@@ -253,6 +256,118 @@ class ArticleMediaTests(unittest.TestCase):
self.assertEqual(migrated["dropbox_file_id"], "id:migrated_file")
self.assertEqual((main.UPLOAD_DIR / media["url"].rsplit("/", 1)[-1]).read_bytes(), b"original notes")
def test_edit_open_before_migration_keeps_old_media_and_new_uploads(self):
from backend.migrate_media_dropbox import migrate
image = io.BytesIO()
Image.new("RGB", (20, 20), "green").save(image, format="PNG")
banner = self.upload(image.getvalue(), "banner").json()
attachment = self.upload(b"original notes", name="notes.txt").json()
draft = {**self.article_payload(), "banner": banner, "attachments": [attachment]}
created = self.client.post("/api/posts", headers=self.headers, json=draft)
self.assertEqual(created.status_code, 201)
path = "/api/posts/an-editable-article"
dropbox = MagicMock()
dropbox.files_upload.side_effect = [
MagicMock(id="id:migrated_banner"), MagicMock(id="id:migrated_attachment"),
MagicMock(id="id:new_attachment"),
]
with patch.object(main.dropbox_storage, "get_dropbox", return_value=dropbox):
migrate(apply=True)
with patch.dict(os.environ, {"JOURNAL_MEDIA_STORAGE": "dropbox"}):
uploaded = self.upload(b"new notes", name="new.txt")
self.assertEqual(uploaded.status_code, 201)
draft["attachments"].append(uploaded.json())
# The browser still has the pre-migration metadata for the old files.
draft["title"] = "An edited article after migration"
saved = self.client.put(path, headers=self.headers, json=draft)
self.assertEqual(saved.status_code, 200, saved.text)
result = self.client.get(path).json()
self.assertEqual(result["title"], draft["title"])
self.assertEqual(result["banner"]["dropbox_file_id"], "id:migrated_banner")
self.assertEqual([m["dropbox_file_id"] for m in result["attachments"]],
["id:migrated_attachment", "id:new_attachment"])
self.assertEqual(result["banner"]["url"], banner["url"])
self.assertEqual(result["attachments"][0]["url"], attachment["url"])
# Compatibility must not accept altered file details or a substituted ID.
for changes in ({"size": 999}, {"name": "different.txt"},
{"media_type": "image/png"}, {"dropbox_file_id": "id:other"},
{"storage": "dropbox"}):
with self.subTest(changes=changes):
invalid = {**draft, "attachments": [{**attachment, **changes}]}
rejected = self.client.put(path, headers=self.headers, json=invalid)
self.assertEqual(rejected.status_code, 422)
def test_removal_deletes_dropbox_files_after_save_and_preserves_shared_media(self):
dropbox = MagicMock()
dropbox.files_upload.side_effect = [MagicMock(id=f"id:file_{i}") for i in range(4)]
image = io.BytesIO()
Image.new("RGB", (8, 8), "blue").save(image, format="PNG")
with patch.dict(os.environ, {"JOURNAL_MEDIA_STORAGE": "dropbox"}), patch.object(main.dropbox_storage, "get_dropbox", return_value=dropbox):
banner = self.upload(image.getvalue(), "banner").json()
attachment = self.upload(b"shared notes").json()
replacement = self.upload(image.getvalue(), "banner").json()
discarded = self.upload(b"removed before publishing").json()
payload = {**self.article_payload(), "banner": banner, "attachments": [attachment]}
self.assertEqual(self.client.post("/api/posts", headers=self.headers, json=payload).status_code, 201)
second = {**self.article_payload(), "title": "Another article with shared notes", "attachments": [attachment]}
self.assertEqual(self.client.post("/api/posts", headers=self.headers, json=second).status_code, 201)
edited = {**payload, "banner": replacement, "attachments": [], "discarded_uploads": [discarded["url"]]}
self.assertEqual(self.client.put("/api/posts/an-editable-article", headers=self.headers, json=edited).status_code, 200)
deleted = [call.args[0] for call in dropbox.files_delete_v2.call_args_list]
self.assertCountEqual(deleted, [banner["dropbox_file_id"], discarded["dropbox_file_id"]])
self.assertEqual(self.client.get(banner["url"]).status_code, 404)
self.assertEqual(main.uploaded_media(attachment["url"].rsplit("/", 1)[-1]), attachment)
self.assertEqual(self.client.delete("/api/posts/another-article-with-shared-notes", headers=self.headers).status_code, 200)
self.assertEqual(self.client.delete("/api/posts/an-editable-article", headers=self.headers).status_code, 200)
self.assertCountEqual([call.args[0] for call in dropbox.files_delete_v2.call_args_list],
[m["dropbox_file_id"] for m in [banner, attachment, replacement, discarded]])
self.assertEqual(main.media_cleanup.pending(), [])
def test_failed_file_deletion_is_durable_and_retryable_after_article_deleted(self):
dropbox = MagicMock()
dropbox.files_upload.return_value.id = "id:retry_delete"
dropbox.files_delete_v2.side_effect = AuthError("request", None)
with patch.dict(os.environ, {"JOURNAL_MEDIA_STORAGE": "dropbox"}), patch.object(main.dropbox_storage, "get_dropbox", return_value=dropbox):
media = self.upload(b"retry notes").json()
payload = {**self.article_payload(), "attachments": [media]}
self.client.post("/api/posts", headers=self.headers, json=payload)
response = self.client.delete("/api/posts/an-editable-article", headers=self.headers)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(main.media_cleanup.pending()), 1)
self.assertEqual(self.client.get("/api/auth/session", headers=self.headers).status_code, 200)
self.assertEqual(main.uploaded_media(media["url"].rsplit("/", 1)[-1]), media)
# Dropbox already removed the file, e.g. before a database outage.
dropbox.files_delete_v2.side_effect = ApiError("request", DeleteError.path_lookup(LookupError.not_found), None, None)
main.media_cleanup.cleanup_pending()
self.assertEqual(main.media_cleanup.pending(), [])
self.assertEqual(self.client.get(media["url"]).status_code, 404)
def test_failed_article_save_does_not_delete_referenced_file(self):
media = self.upload(b"must survive failed save").json()
payload = {**self.article_payload(), "attachments": [media]}
self.client.post("/api/posts", headers=self.headers, json=payload)
with patch.object(main, "save_local_posts", side_effect=OSError("disk unavailable")):
with self.assertRaises(OSError):
self.client.put("/api/posts/an-editable-article", headers=self.headers, json=self.article_payload())
main.read_data.cache_clear()
main.media_cleanup.cleanup_pending()
self.assertEqual(self.client.get(media["url"]).content, b"must survive failed save")
self.assertEqual(len(main.media_cleanup.pending()), 1)
self.assertEqual(self.client.put("/api/posts/an-editable-article", headers=self.headers, json=self.article_payload()).status_code, 200)
self.assertEqual(self.client.get(media["url"]).status_code, 404)
self.assertEqual(main.media_cleanup.pending(), [])
def test_removing_new_upload_before_publish_cleans_it_after_success(self):
media = self.upload(b"discarded draft upload").json()
payload = {**self.article_payload(), "discarded_uploads": [media["url"]]}
invalid = {**payload, "content": {"type": "doc", "content": []}}
self.assertEqual(self.client.post("/api/posts", headers=self.headers, json=invalid).status_code, 422)
self.assertEqual(self.client.get(media["url"]).status_code, 200)
result = self.client.post("/api/posts", headers=self.headers, json=payload)
self.assertEqual(result.status_code, 201)
self.assertNotIn("discarded_uploads", result.json())
self.assertEqual(self.client.get(media["url"]).status_code, 404)
if __name__ == "__main__":
unittest.main()
+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()