From f947ccbd03ba987946162cf9276499b5412c0e01 Mon Sep 17 00:00:00 2001 From: Leo Torres Date: Fri, 27 Mar 2026 08:30:59 +0100 Subject: [PATCH 1/7] Add publish flow for new versions (v2+) of scrolls Support uploading new versions of existing scrolls via ?revises={url_hash} query parameter. The upload page pre-fills metadata from the parent scroll, and the publish flow assigns the correct version number, inherits scroll_series_id/slug/publication_year from the series, and handles duplicate content detection for metadata-only version updates. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/routes/scrolls.py | 159 +++++++++++-- tests/test_version_publish.py | 423 ++++++++++++++++++++++++++++++++++ 2 files changed, 565 insertions(+), 17 deletions(-) create mode 100644 tests/test_version_publish.py diff --git a/app/routes/scrolls.py b/app/routes/scrolls.py index b729a75..ef05904 100644 --- a/app/routes/scrolls.py +++ b/app/routes/scrolls.py @@ -190,11 +190,57 @@ async def confirm_preview( if scroll.user_id != current_user.id: raise HTTPException(status_code=403, detail="Not authorized to publish this preview") - # Publish the scroll and assign year/slug + # Check if this is a new version of an existing scroll + from app.auth.session import get_session + + session_id_for_revise = request.cookies.get("session_id") + revises_hash = None + if session_id_for_revise: + revise_session = get_session(session_id_for_revise) + revises_hash = revise_session.get("revises_scroll") + scroll.publish() - current_year = scroll.published_at.year - scroll.publication_year = current_year - scroll.slug = await generate_unique_slug(db, scroll.title, current_year) + + if revises_hash: + # Publishing a new version -- inherit series metadata from parent + parent_result = await db.execute( + select(Scroll).where( + Scroll.url_hash == revises_hash, + Scroll.status == "published", + Scroll.user_id == current_user.id, + ) + ) + parent_scroll = parent_result.scalar_one_or_none() + + if parent_scroll and parent_scroll.scroll_series_id: + # Find max version in this series + from sqlalchemy import func + + max_version_result = await db.execute( + select(func.max(Scroll.version)).where( + Scroll.scroll_series_id == parent_scroll.scroll_series_id, + Scroll.status == "published", + ) + ) + max_version = max_version_result.scalar() or 1 + + scroll.version = max_version + 1 + scroll.scroll_series_id = parent_scroll.scroll_series_id + scroll.slug = parent_scroll.slug + scroll.publication_year = parent_scroll.publication_year + else: + # Fallback to normal v1 flow if parent invalid + current_year = scroll.published_at.year + scroll.publication_year = current_year + scroll.slug = await generate_unique_slug(db, scroll.title, current_year) + scroll.scroll_series_id = uuid_module.uuid4() + else: + # Normal v1 flow + current_year = scroll.published_at.year + scroll.publication_year = current_year + scroll.slug = await generate_unique_slug(db, scroll.title, current_year) + scroll.scroll_series_id = uuid_module.uuid4() + await db.commit() log_preview_event( @@ -221,13 +267,12 @@ async def confirm_preview( asyncio.create_task(mint_doi_safe(str(scroll.id))) # Clear session data after publishing - from app.auth.session import get_session - - session_id = request.cookies.get("session_id") - if session_id: - session = get_session(session_id) - session.pop("preview_form_data", None) - session.pop("current_preview_url_hash", None) + clear_session_id = request.cookies.get("session_id") + if clear_session_id: + clear_session = get_session(clear_session_id) + clear_session.pop("preview_form_data", None) + clear_session.pop("current_preview_url_hash", None) + clear_session.pop("revises_scroll", None) # Redirect to published scroll via year/slug URL return RedirectResponse(url=f"/{scroll.publication_year}/{scroll.slug}", status_code=303) @@ -699,6 +744,8 @@ async def upload_page(request: Request, db: AsyncSession = Depends(get_db)): scrolls. Unauthenticated users are redirected to login. Loads available academic subjects for categorization. + Accepts optional ?revises={url_hash} to start a new version of an existing scroll. + """ log_request(request) current_user = await get_current_user_from_session(request, db) @@ -713,6 +760,29 @@ async def upload_page(request: Request, db: AsyncSession = Depends(get_db)): # Eagerly load user ID to avoid lazy-load issues user_id = current_user.id + # Handle ?revises= query parameter for new versions + revises_hash = request.query_params.get("revises") + revising_scroll = None + if revises_hash: + from app.auth.session import get_session + + result = await db.execute( + select(Scroll) + .options(selectinload(Scroll.subject)) + .where( + Scroll.url_hash == revises_hash, + Scroll.status == "published", + Scroll.user_id == user_id, + ) + ) + parent = result.scalar_one_or_none() + if parent: + revising_scroll = parent + sid = request.cookies.get("session_id") + if sid: + sess = get_session(sid) + sess["revises_scroll"] = revises_hash + # Load available subjects get_logger().info("Loading subjects for upload form...") try: @@ -789,6 +859,17 @@ async def upload_page(request: Request, db: AsyncSession = Depends(get_db)): session.pop("current_preview_url_hash", None) form_data = None + # Pre-fill from revising scroll if no existing form data + if not form_data and revising_scroll: + form_data = { + "title": revising_scroll.title, + "authors": revising_scroll.authors, + "subject_id": str(revising_scroll.subject_id), + "abstract": revising_scroll.abstract, + "keywords": ", ".join(revising_scroll.keywords) if revising_scroll.keywords else "", + "license": revising_scroll.license, + } + # Re-query user to ensure it's attached to session (cleanup commit may have expired it) from app.models.user import User @@ -810,6 +891,7 @@ async def upload_page(request: Request, db: AsyncSession = Depends(get_db)): "form_data": form_data, "current_drafts": current_drafts, "session": session_data, + "revising_scroll": revising_scroll, }, ) @@ -1143,14 +1225,57 @@ async def upload_form( if session_id: session = get_session(session_id) + # Check if this upload is revising an existing scroll (same series) + revises_series_id = None + if session_id: + revises_hash = session.get("revises_scroll") + if revises_hash: + parent_result = await db.execute( + select(Scroll.scroll_series_id).where( + Scroll.url_hash == revises_hash, + Scroll.status == "published", + Scroll.user_id == current_user.id, + ) + ) + parent_row = parent_result.first() + if parent_row: + revises_series_id = parent_row[0] + if existing: if existing.status == "published": - scroll_link = f"{get_base_url()}/scroll/{existing.url_hash}" - raise ValueError( - f"This content has already been published. Each scroll must have unique content. " - f'View existing scroll. ' - f"If this is a mistake, please contact us at hello@aris.pub" - ) + # Allow same content within the same series (metadata-only version update) + if revises_series_id and existing.scroll_series_id == revises_series_id: + get_logger().info( + f"Same content re-upload allowed for series {revises_series_id} " + f"(metadata-only version update)" + ) + # Generate a unique url_hash for the new version by appending a nonce + import secrets + + nonce = secrets.token_hex(4) + new_url_hash = f"{url_hash}-{nonce}" + scroll = Scroll( + user_id=current_user.id, + title=title, + authors=authors, + subject_id=subject.id, + abstract=abstract, + keywords=keyword_list, + html_content=html_content, + license=license, + content_hash=f"{content_hash}-{nonce}", + url_hash=new_url_hash, + status="preview", + original_filename=original_filename if original_filename else "document.html", + ) + db.add(scroll) + else: + scroll_link = f"{get_base_url()}/scroll/{existing.url_hash}" + raise ValueError( + f"This content has already been published. Each scroll must have unique content. " + f'View existing scroll. ' + f"If this is a mistake, please contact us at hello@aris.pub" + ) elif existing.user_id == current_user.id and existing.status == "preview": # User is resubmitting their own preview - update it instead of creating new existing.title = title diff --git a/tests/test_version_publish.py b/tests/test_version_publish.py new file mode 100644 index 0000000..2fd8e6e --- /dev/null +++ b/tests/test_version_publish.py @@ -0,0 +1,423 @@ +"""Tests for the publish flow for new versions (v2+) of scrolls.""" + +import uuid + +import pytest +import pytest_asyncio +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.session import create_session, get_session +from app.models.scroll import Scroll, Subject +from app.models.user import User +from app.storage.content_processing import generate_permanent_url +from app.utils.slug import generate_unique_slug + + +async def _create_published_v1( + db: AsyncSession, + user: User, + subject: Subject, + title: str = "Neural Networks in Practice", + html_content: str = "

Version 1

Original content

", +) -> Scroll: + """Helper: create a fully published v1 scroll with series fields set.""" + url_hash, content_hash, _ = await generate_permanent_url(db, html_content) + scroll = Scroll( + user_id=user.id, + subject_id=subject.id, + title=title, + authors="Jane Doe", + abstract="An abstract about neural networks.", + keywords=["neural", "networks"], + html_content=html_content, + license="cc-by-4.0", + content_hash=content_hash, + url_hash=url_hash, + status="preview", + ) + db.add(scroll) + await db.commit() + await db.refresh(scroll) + + scroll.publish() + scroll.publication_year = scroll.published_at.year + scroll.slug = await generate_unique_slug(db, scroll.title, scroll.publication_year) + scroll.version = 1 + scroll.scroll_series_id = uuid.uuid4() + await db.commit() + await db.refresh(scroll) + return scroll + + +@pytest.mark.asyncio +class TestUploadPageRevises: + """GET /upload?revises={url_hash} -- pre-fill metadata from parent scroll.""" + + async def test_revises_stores_session_and_prefills( + self, authenticated_client, test_user, test_subject, test_db + ): + """When ?revises= is valid, session stores revises_scroll and form pre-fills.""" + v1 = await _create_published_v1(test_db, test_user, test_subject) + + resp = await authenticated_client.get( + f"/upload?revises={v1.url_hash}", follow_redirects=False + ) + assert resp.status_code == 200 + + # Session should have revises_scroll set + session_id = authenticated_client.cookies.get("session_id") + session = get_session(session_id) + assert session.get("revises_scroll") == v1.url_hash + + # Template should contain pre-filled values from v1 + body = resp.text + assert v1.title in body + assert v1.authors in body + assert v1.abstract in body + + async def test_revises_nonexistent_scroll_ignored( + self, authenticated_client, test_user, test_db + ): + """If revises points to a nonexistent scroll, treat as normal upload.""" + resp = await authenticated_client.get( + "/upload?revises=nonexistent123", follow_redirects=False + ) + assert resp.status_code == 200 + + session_id = authenticated_client.cookies.get("session_id") + session = get_session(session_id) + assert "revises_scroll" not in session + + async def test_revises_not_owner_rejected( + self, authenticated_client, test_subject, test_db + ): + """Cannot revise a scroll owned by someone else.""" + from app.auth.utils import get_password_hash + + other_user = User( + email="other@example.com", + password_hash=get_password_hash("password123"), + display_name="Other User", + email_verified=True, + ) + test_db.add(other_user) + await test_db.commit() + await test_db.refresh(other_user) + + v1 = await _create_published_v1(test_db, other_user, test_subject) + + resp = await authenticated_client.get( + f"/upload?revises={v1.url_hash}", follow_redirects=False + ) + assert resp.status_code == 200 + + session_id = authenticated_client.cookies.get("session_id") + session = get_session(session_id) + assert "revises_scroll" not in session + + async def test_revises_unpublished_scroll_ignored( + self, authenticated_client, test_user, test_subject, test_db + ): + """Cannot revise a scroll that is still in preview status.""" + url_hash, content_hash, _ = await generate_permanent_url( + test_db, "

Draft

" + ) + draft = Scroll( + user_id=test_user.id, + subject_id=test_subject.id, + title="Draft Scroll", + authors="Jane Doe", + abstract="Abstract", + keywords=[], + html_content="

Draft

", + license="cc-by-4.0", + content_hash=content_hash, + url_hash=url_hash, + status="preview", + ) + test_db.add(draft) + await test_db.commit() + await test_db.refresh(draft) + + resp = await authenticated_client.get( + f"/upload?revises={draft.url_hash}", follow_redirects=False + ) + assert resp.status_code == 200 + + session_id = authenticated_client.cookies.get("session_id") + session = get_session(session_id) + assert "revises_scroll" not in session + + +@pytest.mark.asyncio +class TestPublishVersioning: + """POST /preview/{url_hash}/confirm -- version assignment for v2+.""" + + async def _create_preview( + self, + db: AsyncSession, + user: User, + subject: Subject, + html_content: str = "

Version 2

Updated content

", + ) -> Scroll: + url_hash, content_hash, _ = await generate_permanent_url(db, html_content) + scroll = Scroll( + user_id=user.id, + subject_id=subject.id, + title="Neural Networks v2", + authors="Jane Doe", + abstract="Updated abstract", + keywords=["neural"], + html_content=html_content, + license="cc-by-4.0", + content_hash=content_hash, + url_hash=url_hash, + status="preview", + ) + db.add(scroll) + await db.commit() + await db.refresh(scroll) + return scroll + + async def test_publish_v2_inherits_series( + self, authenticated_client, test_user, test_subject, test_db + ): + """Publishing with revises_scroll in session sets correct version, series, slug.""" + v1 = await _create_published_v1(test_db, test_user, test_subject) + preview = await self._create_preview(test_db, test_user, test_subject) + + # Set revises_scroll in session + session_id = authenticated_client.cookies.get("session_id") + session = get_session(session_id) + session["revises_scroll"] = v1.url_hash + + resp = await authenticated_client.post( + f"/preview/{preview.url_hash}/confirm", follow_redirects=False + ) + assert resp.status_code == 303 + + await test_db.refresh(preview) + assert preview.status == "published" + assert preview.version == 2 + assert preview.scroll_series_id == v1.scroll_series_id + assert preview.slug == v1.slug + assert preview.publication_year == v1.publication_year + + async def test_publish_v1_normal_flow( + self, authenticated_client, test_user, test_subject, test_db + ): + """Publishing without revises_scroll uses normal v1 flow.""" + preview = await self._create_preview( + test_db, test_user, test_subject, + html_content="

Brand New

First version

", + ) + + resp = await authenticated_client.post( + f"/preview/{preview.url_hash}/confirm", follow_redirects=False + ) + assert resp.status_code == 303 + + await test_db.refresh(preview) + assert preview.status == "published" + assert preview.version == 1 + assert preview.scroll_series_id is not None + assert preview.slug is not None + assert preview.publication_year is not None + + async def test_publish_v3_increments_correctly( + self, authenticated_client, test_user, test_subject, test_db + ): + """v3 after v2 should have version=3.""" + v1 = await _create_published_v1(test_db, test_user, test_subject) + + # Create and publish v2 + v2_hash, v2_content_hash, _ = await generate_permanent_url( + test_db, "

V2 Content

Version two

" + ) + v2 = Scroll( + user_id=test_user.id, + subject_id=test_subject.id, + title="Neural Networks v2", + authors="Jane Doe", + abstract="v2 abstract", + keywords=[], + html_content="

V2 Content

Version two

", + license="cc-by-4.0", + content_hash=v2_content_hash, + url_hash=v2_hash, + status="preview", + ) + db = test_db + db.add(v2) + await db.commit() + await db.refresh(v2) + + # Publish v2 + v2.publish() + v2.version = 2 + v2.scroll_series_id = v1.scroll_series_id + v2.slug = v1.slug + v2.publication_year = v1.publication_year + await db.commit() + + # Now create v3 preview + v3_preview = await self._create_preview( + test_db, test_user, test_subject, + html_content="

V3 Content

Version three

", + ) + + session_id = authenticated_client.cookies.get("session_id") + session = get_session(session_id) + session["revises_scroll"] = v1.url_hash + + resp = await authenticated_client.post( + f"/preview/{v3_preview.url_hash}/confirm", follow_redirects=False + ) + assert resp.status_code == 303 + + await test_db.refresh(v3_preview) + assert v3_preview.version == 3 + assert v3_preview.scroll_series_id == v1.scroll_series_id + + async def test_publish_clears_revises_session( + self, authenticated_client, test_user, test_subject, test_db + ): + """Session's revises_scroll is cleared after publishing.""" + v1 = await _create_published_v1(test_db, test_user, test_subject) + preview = await self._create_preview(test_db, test_user, test_subject) + + session_id = authenticated_client.cookies.get("session_id") + session = get_session(session_id) + session["revises_scroll"] = v1.url_hash + + await authenticated_client.post( + f"/preview/{preview.url_hash}/confirm", follow_redirects=False + ) + + assert "revises_scroll" not in session + + async def test_publish_v2_own_url_hash( + self, authenticated_client, test_user, test_subject, test_db + ): + """v2 gets its own unique url_hash and content_hash.""" + v1 = await _create_published_v1(test_db, test_user, test_subject) + preview = await self._create_preview(test_db, test_user, test_subject) + + session_id = authenticated_client.cookies.get("session_id") + session = get_session(session_id) + session["revises_scroll"] = v1.url_hash + + await authenticated_client.post( + f"/preview/{preview.url_hash}/confirm", follow_redirects=False + ) + + await test_db.refresh(preview) + assert preview.url_hash != v1.url_hash + assert preview.content_hash != v1.content_hash + + +@pytest.mark.asyncio +class TestDuplicateContentHandling: + """Duplicate content detection with version awareness.""" + + async def test_same_content_same_series_allowed( + self, authenticated_client, test_user, test_subject, test_db + ): + """Same content_hash within the same series should warn but allow (metadata-only update).""" + v1 = await _create_published_v1(test_db, test_user, test_subject) + + session_id = authenticated_client.cookies.get("session_id") + session = get_session(session_id) + session["revises_scroll"] = v1.url_hash + + # Upload same content as v1 + resp = await authenticated_client.post( + "/upload-form", + data={ + "title": "Updated Title Only", + "authors": "Jane Doe", + "subject_id": str(test_subject.id), + "abstract": "Updated abstract only", + "keywords": "neural,networks", + "license": "cc-by-4.0", + "confirm_rights": "true", + "action": "publish", + }, + files={"file": ("paper.html", v1.html_content.encode(), "text/html")}, + follow_redirects=False, + ) + # Should succeed (redirect to preview) rather than 422 error + assert resp.status_code == 303 or resp.status_code == 200 + + async def test_same_content_different_series_rejected( + self, authenticated_client, test_user, test_subject, test_db + ): + """Same content_hash from a DIFFERENT series should still be rejected.""" + v1 = await _create_published_v1(test_db, test_user, test_subject) + + # No revises_scroll in session -- normal upload + resp = await authenticated_client.post( + "/upload-form", + data={ + "title": "Totally Different Paper", + "authors": "John Smith", + "subject_id": str(test_subject.id), + "abstract": "Different abstract", + "keywords": "", + "license": "cc-by-4.0", + "confirm_rights": "true", + "action": "publish", + }, + files={"file": ("paper.html", v1.html_content.encode(), "text/html")}, + follow_redirects=False, + ) + # Should be rejected (422 with error message) + assert resp.status_code == 422 + assert "already been published" in resp.text + + +@pytest.mark.asyncio +class TestOwnershipValidation: + """Only scroll owners can upload new versions.""" + + async def test_non_owner_cannot_revise( + self, client, test_subject, test_db + ): + """A different user cannot use revises for someone else's scroll.""" + from app.auth.utils import get_password_hash + + # Create owner and their scroll + owner = User( + email="owner@example.com", + password_hash=get_password_hash("password123"), + display_name="Owner", + email_verified=True, + ) + test_db.add(owner) + await test_db.commit() + await test_db.refresh(owner) + + v1 = await _create_published_v1(test_db, owner, test_subject) + + # Create a different user and authenticate as them + other_user = User( + email="other2@example.com", + password_hash=get_password_hash("password123"), + display_name="Other", + email_verified=True, + ) + test_db.add(other_user) + await test_db.commit() + await test_db.refresh(other_user) + + other_session_id = await create_session(test_db, other_user.id) + client.cookies.set("session_id", other_session_id) + + resp = await client.get( + f"/upload?revises={v1.url_hash}", follow_redirects=False + ) + assert resp.status_code == 200 + + session = get_session(other_session_id) + assert "revises_scroll" not in session From 1b56a93d509775551b9a1590bbdb6d1fc5775645 Mon Sep 17 00:00:00 2001 From: Leo Torres Date: Fri, 27 Mar 2026 08:42:10 +0100 Subject: [PATCH 2/7] Fix content_hash exceeding VARCHAR(64) for same-series re-uploads Truncate base content_hash before appending nonce so the combined value stays within the 64-character column limit. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/routes/scrolls.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/routes/scrolls.py b/app/routes/scrolls.py index ef05904..d3c13ce 100644 --- a/app/routes/scrolls.py +++ b/app/routes/scrolls.py @@ -1254,6 +1254,9 @@ async def upload_form( nonce = secrets.token_hex(4) new_url_hash = f"{url_hash}-{nonce}" + # Truncate content_hash so that "{hash}-{nonce}" fits VARCHAR(64) + max_base_len = 64 - len(nonce) - 1 + truncated_hash = content_hash[:max_base_len] scroll = Scroll( user_id=current_user.id, title=title, @@ -1263,7 +1266,7 @@ async def upload_form( keywords=keyword_list, html_content=html_content, license=license, - content_hash=f"{content_hash}-{nonce}", + content_hash=f"{truncated_hash}-{nonce}", url_hash=new_url_hash, status="preview", original_filename=original_filename if original_filename else "document.html", From e330ff27371ae785573707aa605a2410728d19d7 Mon Sep 17 00:00:00 2001 From: Leo Torres Date: Fri, 27 Mar 2026 09:04:45 +0100 Subject: [PATCH 3/7] Fix url_hash exceeding VARCHAR(20) for same-series re-uploads The nonce-appended url_hash was not being truncated to fit within the 20-character column limit. Truncate the base url_hash to (20 - nonce_length - 1) characters before appending the nonce. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/routes/scrolls.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/routes/scrolls.py b/app/routes/scrolls.py index d3c13ce..91febeb 100644 --- a/app/routes/scrolls.py +++ b/app/routes/scrolls.py @@ -1253,7 +1253,9 @@ async def upload_form( import secrets nonce = secrets.token_hex(4) - new_url_hash = f"{url_hash}-{nonce}" + # Truncate url_hash so that "{hash}-{nonce}" fits VARCHAR(20) + max_url_len = 20 - len(nonce) - 1 + new_url_hash = f"{url_hash[:max_url_len]}-{nonce}" # Truncate content_hash so that "{hash}-{nonce}" fits VARCHAR(64) max_base_len = 64 - len(nonce) - 1 truncated_hash = content_hash[:max_base_len] From aa74278235ba747b2449f2f084f4d401e6308d5c Mon Sep 17 00:00:00 2001 From: Leo Torres Date: Fri, 27 Mar 2026 10:08:34 +0100 Subject: [PATCH 4/7] Add 'New Version' link on published scroll page for owners Scroll owners now see a "New Version" button in the scroll details modal that links to /upload?revises={url_hash}, making it easy to start a new version without needing to know the url_hash. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/routes/scrolls.py | 10 ++++- app/templates/scroll.html | 3 ++ tests/test_version_publish.py | 80 +++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/app/routes/scrolls.py b/app/routes/scrolls.py index 91febeb..b2ecec3 100644 --- a/app/routes/scrolls.py +++ b/app/routes/scrolls.py @@ -510,8 +510,11 @@ async def view_scroll(request: Request, identifier: str, db: AsyncSession = Depe extra_data={"title": scroll.title, "url_hash": scroll.url_hash}, ) + current_user = await get_current_user_from_session(request, db) + is_owner = current_user is not None and scroll.user_id == current_user.id + return templates.TemplateResponse( - request, "scroll.html", {"scroll": scroll, "base_url": get_base_url()} + request, "scroll.html", {"scroll": scroll, "base_url": get_base_url(), "is_owner": is_owner} ) @@ -549,8 +552,11 @@ async def view_scroll_by_year_slug( extra_data={"title": scroll.title, "url_hash": scroll.url_hash}, ) + current_user = await get_current_user_from_session(request, db) + is_owner = current_user is not None and scroll.user_id == current_user.id + return templates.TemplateResponse( - request, "scroll.html", {"scroll": scroll, "base_url": get_base_url()} + request, "scroll.html", {"scroll": scroll, "base_url": get_base_url(), "is_owner": is_owner} ) diff --git a/app/templates/scroll.html b/app/templates/scroll.html index 5e349f3..cce3bbf 100644 --- a/app/templates/scroll.html +++ b/app/templates/scroll.html @@ -182,6 +182,9 @@ diff --git a/tests/test_version_publish.py b/tests/test_version_publish.py index 2fd8e6e..e0150e8 100644 --- a/tests/test_version_publish.py +++ b/tests/test_version_publish.py @@ -421,3 +421,83 @@ async def test_non_owner_cannot_revise( session = get_session(other_session_id) assert "revises_scroll" not in session + + +@pytest.mark.asyncio +class TestNewVersionLink: + """Published scroll page shows 'New Version' link to the scroll owner.""" + + async def test_owner_sees_new_version_link( + self, authenticated_client, test_user, test_subject, test_db + ): + """The scroll owner should see a 'New Version' link on the published scroll page.""" + v1 = await _create_published_v1(test_db, test_user, test_subject) + + resp = await authenticated_client.get( + f"/{v1.publication_year}/{v1.slug}", follow_redirects=False + ) + assert resp.status_code == 200 + assert f"/upload?revises={v1.url_hash}" in resp.text + assert "New Version" in resp.text + + async def test_non_owner_does_not_see_new_version_link( + self, client, test_subject, test_db + ): + """A different user should NOT see the 'New Version' link.""" + from app.auth.utils import get_password_hash + + owner = User( + email="owner3@example.com", + password_hash=get_password_hash("password123"), + display_name="Owner", + email_verified=True, + ) + test_db.add(owner) + await test_db.commit() + await test_db.refresh(owner) + + v1 = await _create_published_v1(test_db, owner, test_subject) + + other_user = User( + email="viewer@example.com", + password_hash=get_password_hash("password123"), + display_name="Viewer", + email_verified=True, + ) + test_db.add(other_user) + await test_db.commit() + await test_db.refresh(other_user) + + other_session_id = await create_session(test_db, other_user.id) + client.cookies.set("session_id", other_session_id) + + resp = await client.get( + f"/{v1.publication_year}/{v1.slug}", follow_redirects=False + ) + assert resp.status_code == 200 + assert f"/upload?revises={v1.url_hash}" not in resp.text + + async def test_anonymous_does_not_see_new_version_link( + self, client, test_user, test_subject, test_db + ): + """An unauthenticated user should NOT see the 'New Version' link.""" + v1 = await _create_published_v1(test_db, test_user, test_subject) + + resp = await client.get( + f"/{v1.publication_year}/{v1.slug}", follow_redirects=False + ) + assert resp.status_code == 200 + assert f"/upload?revises={v1.url_hash}" not in resp.text + assert "New Version" not in resp.text + + async def test_owner_sees_link_on_hash_route( + self, authenticated_client, test_user, test_subject, test_db + ): + """The 'New Version' link also appears on the /scroll/{url_hash} route.""" + v1 = await _create_published_v1(test_db, test_user, test_subject) + + resp = await authenticated_client.get( + f"/scroll/{v1.url_hash}", follow_redirects=False + ) + assert resp.status_code == 200 + assert f"/upload?revises={v1.url_hash}" in resp.text From 6616656a40dd67b8b8dd8de37253af522c5bd8c4 Mon Sep 17 00:00:00 2001 From: Leo Torres Date: Fri, 27 Mar 2026 13:02:37 +0100 Subject: [PATCH 5/7] Show upcoming version number on preview page for new versions The preview page now indicates "This will be published as v2" (or v3, etc.) when a scroll is being uploaded as a new version via the revises flow. Also adds a full integration test for publishing same-content v2 scrolls. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/routes/scrolls.py | 31 ++++++++- app/templates/preview.html | 4 +- tests/test_version_publish.py | 119 ++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 3 deletions(-) diff --git a/app/routes/scrolls.py b/app/routes/scrolls.py index b2ecec3..9e2d5cd 100644 --- a/app/routes/scrolls.py +++ b/app/routes/scrolls.py @@ -128,11 +128,35 @@ async def view_preview(request: Request, url_hash: str, db: AsyncSession = Depen from app.auth.session import get_session session_id = request.cookies.get("session_id") + upcoming_version = None if session_id: session = get_session(session_id) session.pop("preview_form_data", None) session.pop("current_preview_url_hash", None) + # Check if this is a new version of an existing scroll + revises_hash = session.get("revises_scroll") + if revises_hash: + from sqlalchemy import func + + parent_result = await db.execute( + select(Scroll).where( + Scroll.url_hash == revises_hash, + Scroll.status == "published", + Scroll.user_id == current_user.id, + ) + ) + parent_scroll = parent_result.scalar_one_or_none() + if parent_scroll and parent_scroll.scroll_series_id: + max_version_result = await db.execute( + select(func.max(Scroll.version)).where( + Scroll.scroll_series_id == parent_scroll.scroll_series_id, + Scroll.status == "published", + ) + ) + max_version = max_version_result.scalar() or 1 + upcoming_version = max_version + 1 + # Update last_accessed_at scroll.last_accessed_at = datetime.now(timezone.utc) await db.commit() @@ -146,7 +170,12 @@ async def view_preview(request: Request, url_hash: str, db: AsyncSession = Depen return templates.TemplateResponse( request, "preview.html", - {"scroll": scroll, "current_user": current_user, "csrf_token": csrf_token}, + { + "scroll": scroll, + "current_user": current_user, + "csrf_token": csrf_token, + "upcoming_version": upcoming_version, + }, ) diff --git a/app/templates/preview.html b/app/templates/preview.html index ae8ee9c..5ae4c5c 100644 --- a/app/templates/preview.html +++ b/app/templates/preview.html @@ -18,7 +18,7 @@
⚠️ - PREVIEW MODE - Review your scroll below + PREVIEW MODE{% if upcoming_version %} - This will be published as v{{ upcoming_version }}{% else %} - Review your scroll below{% endif %}
@@ -59,7 +59,7 @@ {{ scroll.authors }} • {{ scroll.subject.name }}
{% endif %} - + {{ form_input("Title", "title", "text", form_data.title if form_data else "", required=true, help_text="A clear, descriptive title for your research scroll") }} diff --git a/tests/test_version_publish.py b/tests/test_version_publish.py index 6f517a4..f368541 100644 --- a/tests/test_version_publish.py +++ b/tests/test_version_publish.py @@ -620,3 +620,146 @@ async def test_owner_sees_link_on_hash_route( ) assert resp.status_code == 200 assert f"/upload?revises={v1.url_hash}" in resp.text + + +@pytest.mark.asyncio +class TestYearSlugWithMultipleVersions: + """GET /{year}/{slug} should return the latest version when multiple exist.""" + + async def test_year_slug_returns_latest_version( + self, authenticated_client, test_user, test_subject, test_db + ): + """When v1 and v2 share the same year/slug, the route should return the latest.""" + v1 = await _create_published_v1(test_db, test_user, test_subject) + + # Create v2 with the same year/slug/series + url_hash2, content_hash2, _ = await generate_permanent_url( + test_db, "

Version 2

Updated content for v2

" + ) + v2 = Scroll( + user_id=test_user.id, + subject_id=test_subject.id, + title="Neural Networks v2", + authors="Jane Doe", + abstract="Updated abstract", + keywords=["neural"], + html_content="

Version 2

Updated content for v2

", + license="cc-by-4.0", + content_hash=content_hash2, + url_hash=url_hash2, + status="preview", + ) + test_db.add(v2) + await test_db.commit() + await test_db.refresh(v2) + + v2.publish() + v2.version = 2 + v2.scroll_series_id = v1.scroll_series_id + v2.slug = v1.slug + v2.publication_year = v1.publication_year + await test_db.commit() + await test_db.refresh(v2) + + resp = await authenticated_client.get( + f"/{v1.publication_year}/{v1.slug}", follow_redirects=False + ) + assert resp.status_code == 200 + assert "Neural Networks v2" in resp.text + + async def test_publish_v2_redirects_to_year_slug_without_500( + self, authenticated_client, test_user, test_subject, test_db + ): + """Publishing v2 should redirect to year/slug URL without a 500 error.""" + v1 = await _create_published_v1(test_db, test_user, test_subject) + + # Create a preview for v2 + url_hash2, content_hash2, _ = await generate_permanent_url( + test_db, "

Version 2

New content for publish test

" + ) + preview = Scroll( + user_id=test_user.id, + subject_id=test_subject.id, + title="Neural Networks v2", + authors="Jane Doe", + abstract="Updated abstract", + keywords=["neural"], + html_content="

Version 2

New content for publish test

", + license="cc-by-4.0", + content_hash=content_hash2, + url_hash=url_hash2, + status="preview", + ) + test_db.add(preview) + await test_db.commit() + await test_db.refresh(preview) + + # Set revises_scroll in session + session_id = authenticated_client.cookies.get("session_id") + session = get_session(session_id) + session["revises_scroll"] = v1.url_hash + + # Publish v2 + resp = await authenticated_client.post( + f"/preview/{preview.url_hash}/confirm", follow_redirects=False + ) + assert resp.status_code == 303 + + # Follow the redirect to year/slug -- should NOT 500 + redirect_url = resp.headers["location"] + resp2 = await authenticated_client.get(redirect_url, follow_redirects=False) + assert resp2.status_code == 200 + + +@pytest.mark.asyncio +class TestMetadataOnlyRevision: + """Revision upload without re-uploading HTML file (metadata-only version update).""" + + async def test_revision_without_file_uses_parent_content( + self, authenticated_client, test_user, test_subject, test_db + ): + """Submitting revision form without a file should use the parent scroll's HTML.""" + v1 = await _create_published_v1(test_db, test_user, test_subject) + + session_id = authenticated_client.cookies.get("session_id") + session = get_session(session_id) + session["revises_scroll"] = v1.url_hash + + # Submit form WITHOUT a file + resp = await authenticated_client.post( + "/upload-form", + data={ + "title": "Updated Title Only", + "authors": "Jane Doe", + "subject_id": str(test_subject.id), + "abstract": "Updated abstract only", + "keywords": "neural,networks", + "license": "cc-by-4.0", + "confirm_rights": "true", + "action": "publish", + }, + follow_redirects=False, + ) + # Should succeed (redirect to preview) -- not 422 "HTML file is required" + assert resp.status_code == 303 + + async def test_revision_without_file_and_no_revises_still_requires_file( + self, authenticated_client, test_user, test_subject, test_db + ): + """Without revises_scroll in session, no file should still fail.""" + resp = await authenticated_client.post( + "/upload-form", + data={ + "title": "Some Title", + "authors": "Jane Doe", + "subject_id": str(test_subject.id), + "abstract": "Some abstract", + "keywords": "", + "license": "cc-by-4.0", + "confirm_rights": "true", + "action": "publish", + }, + follow_redirects=False, + ) + assert resp.status_code == 422 + assert "HTML file is required" in resp.text From 79280eb52b5d8b4cffe81c57a090416f9ae4f1bc Mon Sep 17 00:00:00 2001 From: Leo Torres Date: Fri, 27 Mar 2026 15:09:39 +0100 Subject: [PATCH 7/7] Fix e2e footer tests: update selector for canonical scroll URLs The homepage scroll cards now use canonical /{year}/{slug} URLs via the .scroll-card-link class instead of /scroll/{hash} hrefs. The footer tests were timing out because a[href^="/scroll/"] matched nothing. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/e2e/test_footer.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/e2e/test_footer.py b/tests/e2e/test_footer.py index 799f361..127ab3d 100644 --- a/tests/e2e/test_footer.py +++ b/tests/e2e/test_footer.py @@ -17,7 +17,7 @@ async def test_footer_structure_light_mode(test_server): await page.wait_for_load_state("networkidle") # Click on the first scroll to view it - first_scroll_link = page.locator('a[href^="/scroll/"]').first + first_scroll_link = page.locator("a.scroll-card-link").first await first_scroll_link.click() await page.wait_for_load_state("networkidle") @@ -67,7 +67,7 @@ async def test_footer_dark_mode(test_server): await page.goto(f"{test_server}/") await page.wait_for_load_state("networkidle") - first_scroll_link = page.locator('a[href^="/scroll/"]').first + first_scroll_link = page.locator("a.scroll-card-link").first await first_scroll_link.click() await page.wait_for_load_state("networkidle") @@ -116,7 +116,7 @@ async def test_footer_mobile_responsive(test_server): await page.goto(f"{test_server}/") await page.wait_for_load_state("networkidle") - first_scroll_link = page.locator('a[href^="/scroll/"]').first + first_scroll_link = page.locator("a.scroll-card-link").first await first_scroll_link.click() await page.wait_for_load_state("networkidle") @@ -154,7 +154,7 @@ async def test_footer_cta_links_work(test_server): await page.goto(f"{test_server}/") await page.wait_for_load_state("networkidle") - first_scroll_link = page.locator('a[href^="/scroll/"]').first + first_scroll_link = page.locator("a.scroll-card-link").first await first_scroll_link.click() await page.wait_for_load_state("networkidle") @@ -197,7 +197,7 @@ async def test_footer_aris_link_external(test_server): await page.goto(f"{test_server}/") await page.wait_for_load_state("networkidle") - first_scroll_link = page.locator('a[href^="/scroll/"]').first + first_scroll_link = page.locator("a.scroll-card-link").first await first_scroll_link.click() await page.wait_for_load_state("networkidle") @@ -230,7 +230,7 @@ async def test_footer_license_display_cc_by(test_server): await page.wait_for_load_state("networkidle") # Find a scroll (seed data should have CC BY scrolls) - first_scroll_link = page.locator('a[href^="/scroll/"]').first + first_scroll_link = page.locator("a.scroll-card-link").first await first_scroll_link.click() await page.wait_for_load_state("networkidle")