2026-04-06 19:33:09 -04:00
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
from datetime import date, datetime
|
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
2026-04-07 02:22:35 -04:00
|
|
|
import requests
|
2026-04-06 19:33:09 -04:00
|
|
|
from PIL import Image as PILImage
|
|
|
|
|
|
|
|
|
|
from app import db
|
|
|
|
|
from src.models import Article, Image, Issue
|
2026-04-07 02:22:35 -04:00
|
|
|
import config
|
2026-04-06 19:33:09 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_regenerate_passes_ordered_image_paths_to_generate_cover(app, client, db, tmp_path):
|
|
|
|
|
"""First image per article, in pub_date order, passed to generate_cover."""
|
|
|
|
|
os.makedirs(tmp_path, exist_ok=True)
|
|
|
|
|
img1 = str(tmp_path / "a1.jpg")
|
|
|
|
|
img2 = str(tmp_path / "a2.jpg")
|
|
|
|
|
for p in (img1, img2):
|
|
|
|
|
PILImage.new("RGB", (100, 100), color="blue").save(p, format="JPEG")
|
|
|
|
|
|
|
|
|
|
with app.app_context():
|
|
|
|
|
a1 = Article(
|
|
|
|
|
guid="g1",
|
|
|
|
|
title="Earlier Article",
|
|
|
|
|
author="A",
|
|
|
|
|
pub_date=datetime(2026, 4, 6, 10, 0),
|
|
|
|
|
categories=json.dumps(["Government"]),
|
|
|
|
|
link="http://example.com/1",
|
|
|
|
|
content_html="<p>x</p>",
|
|
|
|
|
)
|
|
|
|
|
a2 = Article(
|
|
|
|
|
guid="g2",
|
|
|
|
|
title="Later Article",
|
|
|
|
|
author="B",
|
|
|
|
|
pub_date=datetime(2026, 4, 7, 10, 0),
|
|
|
|
|
categories=json.dumps(["Culture"]),
|
|
|
|
|
link="http://example.com/2",
|
|
|
|
|
content_html="<p>y</p>",
|
|
|
|
|
)
|
|
|
|
|
db.session.add_all([a1, a2])
|
|
|
|
|
db.session.flush()
|
|
|
|
|
db.session.add_all(
|
|
|
|
|
[
|
|
|
|
|
Image(
|
|
|
|
|
article_id=a1.id,
|
|
|
|
|
original_url="https://example.com/1.jpg",
|
|
|
|
|
local_path=img1,
|
|
|
|
|
width=100,
|
|
|
|
|
height=100,
|
|
|
|
|
),
|
|
|
|
|
Image(
|
|
|
|
|
article_id=a2.id,
|
|
|
|
|
original_url="https://example.com/2.jpg",
|
|
|
|
|
local_path=img2,
|
|
|
|
|
width=100,
|
|
|
|
|
height=100,
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
id1, id2 = a1.id, a2.id
|
|
|
|
|
issue = Issue(
|
|
|
|
|
week_start=date(2026, 4, 6),
|
|
|
|
|
week_end=date(2026, 4, 12),
|
|
|
|
|
cover_method="mosaic",
|
|
|
|
|
cover_path=str(tmp_path / "old-cover.jpg"),
|
|
|
|
|
epub_path=str(tmp_path / "old.epub"),
|
|
|
|
|
article_ids=json.dumps([id2, id1]),
|
|
|
|
|
status="published",
|
|
|
|
|
)
|
|
|
|
|
db.session.add(issue)
|
|
|
|
|
db.session.commit()
|
|
|
|
|
issue_id = issue.id
|
|
|
|
|
|
|
|
|
|
mock_cover = MagicMock(return_value=str(tmp_path / "new-cover.jpg"))
|
|
|
|
|
mock_epub = MagicMock(return_value=str(tmp_path / "new.epub"))
|
|
|
|
|
|
|
|
|
|
with patch("src.routes.issues.generate_cover", mock_cover), patch(
|
|
|
|
|
"src.routes.issues.build_epub", mock_epub
|
|
|
|
|
):
|
|
|
|
|
resp = client.post(f"/issues/{issue_id}/regenerate")
|
|
|
|
|
|
|
|
|
|
assert resp.status_code in (302, 303)
|
|
|
|
|
mock_cover.assert_called_once()
|
|
|
|
|
args = mock_cover.call_args[0]
|
|
|
|
|
assert args[0] == "mosaic"
|
|
|
|
|
assert args[4] == ["Earlier Article", "Later Article"]
|
|
|
|
|
assert args[5] == [img1, img2]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_regenerate_maps_ai_cover_method_to_mosaic(app, client, db, tmp_path):
|
|
|
|
|
"""Legacy ai issues use mosaic generation and persist cover_method=mosaic."""
|
|
|
|
|
os.makedirs(tmp_path, exist_ok=True)
|
|
|
|
|
img1 = str(tmp_path / "a1.jpg")
|
|
|
|
|
PILImage.new("RGB", (100, 100), color="blue").save(img1, format="JPEG")
|
|
|
|
|
|
|
|
|
|
with app.app_context():
|
|
|
|
|
a1 = Article(
|
|
|
|
|
guid="g-ai",
|
|
|
|
|
title="Some Article",
|
|
|
|
|
author="A",
|
|
|
|
|
pub_date=datetime(2026, 4, 6, 10, 0),
|
|
|
|
|
categories=json.dumps(["Government"]),
|
|
|
|
|
link="http://example.com/1",
|
|
|
|
|
content_html="<p>x</p>",
|
|
|
|
|
)
|
|
|
|
|
db.session.add(a1)
|
|
|
|
|
db.session.flush()
|
|
|
|
|
db.session.add(
|
|
|
|
|
Image(
|
|
|
|
|
article_id=a1.id,
|
|
|
|
|
original_url="https://example.com/1.jpg",
|
|
|
|
|
local_path=img1,
|
|
|
|
|
width=100,
|
|
|
|
|
height=100,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
issue = Issue(
|
|
|
|
|
week_start=date(2026, 4, 6),
|
|
|
|
|
week_end=date(2026, 4, 12),
|
|
|
|
|
cover_method="ai",
|
|
|
|
|
cover_path=str(tmp_path / "old-cover.jpg"),
|
|
|
|
|
epub_path=str(tmp_path / "old.epub"),
|
|
|
|
|
article_ids=json.dumps([a1.id]),
|
|
|
|
|
status="published",
|
|
|
|
|
)
|
|
|
|
|
db.session.add(issue)
|
|
|
|
|
db.session.commit()
|
|
|
|
|
issue_id = issue.id
|
|
|
|
|
|
|
|
|
|
mock_cover = MagicMock(return_value=str(tmp_path / "new-cover.jpg"))
|
|
|
|
|
mock_epub = MagicMock(return_value=str(tmp_path / "new.epub"))
|
|
|
|
|
|
|
|
|
|
with patch("src.routes.issues.generate_cover", mock_cover), patch(
|
|
|
|
|
"src.routes.issues.build_epub", mock_epub
|
|
|
|
|
):
|
|
|
|
|
client.post(f"/issues/{issue_id}/regenerate")
|
|
|
|
|
|
|
|
|
|
mock_cover.assert_called_once()
|
|
|
|
|
assert mock_cover.call_args[0][0] == "mosaic"
|
|
|
|
|
|
|
|
|
|
with app.app_context():
|
|
|
|
|
updated = db.session.get(Issue, issue_id)
|
|
|
|
|
assert updated.cover_method == "mosaic"
|
2026-04-07 02:22:35 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_push_issue_success(app, client, db, tmp_path):
|
|
|
|
|
"""Test successful push to Grimmory API."""
|
|
|
|
|
os.makedirs(tmp_path, exist_ok=True)
|
|
|
|
|
epub_path = tmp_path / "test.epub"
|
|
|
|
|
epub_path.write_text("dummy epub content")
|
|
|
|
|
|
|
|
|
|
with app.app_context():
|
|
|
|
|
issue = Issue(
|
|
|
|
|
week_start=date(2026, 4, 6),
|
|
|
|
|
week_end=date(2026, 4, 12),
|
|
|
|
|
cover_method="mosaic",
|
|
|
|
|
cover_path=str(tmp_path / "cover.jpg"),
|
|
|
|
|
epub_path=str(epub_path),
|
|
|
|
|
article_ids=json.dumps([1]),
|
|
|
|
|
status="published",
|
|
|
|
|
)
|
|
|
|
|
db.session.add(issue)
|
|
|
|
|
db.session.commit()
|
|
|
|
|
issue_id = issue.id
|
|
|
|
|
|
|
|
|
|
mock_login_resp = MagicMock()
|
|
|
|
|
mock_login_resp.status_code = 200
|
|
|
|
|
mock_login_resp.json.return_value = {"token": "test_token"}
|
|
|
|
|
|
|
|
|
|
mock_libraries_resp = MagicMock()
|
|
|
|
|
mock_libraries_resp.status_code = 200
|
|
|
|
|
mock_libraries_resp.json.return_value = [{"id": 123, "name": "lib123", "paths": [{"id": 456}]}]
|
|
|
|
|
|
|
|
|
|
mock_upload_resp = MagicMock()
|
|
|
|
|
mock_upload_resp.status_code = 200
|
|
|
|
|
|
|
|
|
|
def mock_post(url, *args, **kwargs):
|
|
|
|
|
if url.endswith("/api/v1/auth/login"):
|
|
|
|
|
return mock_login_resp
|
|
|
|
|
elif url.endswith("/api/v1/files/upload"):
|
|
|
|
|
return mock_upload_resp
|
|
|
|
|
return MagicMock(status_code=404)
|
|
|
|
|
|
|
|
|
|
def mock_get(url, *args, **kwargs):
|
|
|
|
|
if url.endswith("/api/v1/libraries"):
|
|
|
|
|
return mock_libraries_resp
|
|
|
|
|
return MagicMock(status_code=404)
|
|
|
|
|
|
|
|
|
|
with patch("src.routes.issues.requests.post", side_effect=mock_post) as mock_post_call, \
|
|
|
|
|
patch("src.routes.issues.requests.get", side_effect=mock_get) as mock_get_call:
|
|
|
|
|
with patch.object(config, "GRIMMORY_URL", "http://grimmory.test"), \
|
|
|
|
|
patch.object(config, "GRIMMORY_USERNAME", "admin"), \
|
|
|
|
|
patch.object(config, "GRIMMORY_PASSWORD", "password"), \
|
|
|
|
|
patch.object(config, "GRIMMORY_LIBRARY_ID", "lib123"):
|
|
|
|
|
|
|
|
|
|
resp = client.post(f"/issues/{issue_id}/push")
|
|
|
|
|
|
|
|
|
|
assert resp.status_code in (302, 303)
|
|
|
|
|
assert mock_post_call.call_count == 2
|
|
|
|
|
assert mock_get_call.call_count == 1
|
|
|
|
|
|
|
|
|
|
# Check login call
|
|
|
|
|
login_args, login_kwargs = mock_post_call.call_args_list[0]
|
|
|
|
|
assert login_args[0] == "http://grimmory.test/api/v1/auth/login"
|
|
|
|
|
assert login_kwargs["json"] == {"username": "admin", "password": "password"}
|
|
|
|
|
|
|
|
|
|
# Check libraries call
|
|
|
|
|
get_args, get_kwargs = mock_get_call.call_args_list[0]
|
|
|
|
|
assert get_args[0] == "http://grimmory.test/api/v1/libraries"
|
|
|
|
|
assert get_kwargs["headers"] == {"Authorization": "Bearer test_token"}
|
|
|
|
|
|
|
|
|
|
# Check upload call
|
|
|
|
|
upload_args, upload_kwargs = mock_post_call.call_args_list[1]
|
|
|
|
|
assert upload_args[0] == "http://grimmory.test/api/v1/files/upload"
|
|
|
|
|
assert upload_kwargs["headers"] == {"Authorization": "Bearer test_token"}
|
|
|
|
|
assert upload_kwargs["data"] == {"libraryId": 123, "pathId": 456}
|
|
|
|
|
assert "file" in upload_kwargs["files"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_push_issue_missing_config(app, client, db, tmp_path):
|
|
|
|
|
"""Test push fails gracefully when config is missing."""
|
|
|
|
|
with app.app_context():
|
|
|
|
|
issue = Issue(
|
|
|
|
|
week_start=date(2026, 4, 6),
|
|
|
|
|
week_end=date(2026, 4, 12),
|
|
|
|
|
cover_method="mosaic",
|
|
|
|
|
cover_path=str(tmp_path / "cover.jpg"),
|
|
|
|
|
epub_path=str(tmp_path / "test.epub"),
|
|
|
|
|
article_ids=json.dumps([1]),
|
|
|
|
|
status="published",
|
|
|
|
|
)
|
|
|
|
|
db.session.add(issue)
|
|
|
|
|
db.session.commit()
|
|
|
|
|
issue_id = issue.id
|
|
|
|
|
|
|
|
|
|
with patch("src.routes.issues.requests.post") as mock_post:
|
|
|
|
|
with patch.object(config, "GRIMMORY_URL", None), \
|
|
|
|
|
patch.object(config, "GRIMMORY_USERNAME", None), \
|
|
|
|
|
patch.object(config, "GRIMMORY_PASSWORD", None):
|
|
|
|
|
|
|
|
|
|
resp = client.post(f"/issues/{issue_id}/push")
|
|
|
|
|
|
|
|
|
|
assert resp.status_code in (302, 303)
|
|
|
|
|
mock_post.assert_not_called()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_push_issue_missing_file(app, client, db, tmp_path):
|
|
|
|
|
"""Test push fails gracefully when epub file is missing."""
|
|
|
|
|
with app.app_context():
|
|
|
|
|
issue = Issue(
|
|
|
|
|
week_start=date(2026, 4, 6),
|
|
|
|
|
week_end=date(2026, 4, 12),
|
|
|
|
|
cover_method="mosaic",
|
|
|
|
|
cover_path=str(tmp_path / "cover.jpg"),
|
|
|
|
|
epub_path="/nonexistent/path/test.epub",
|
|
|
|
|
article_ids=json.dumps([1]),
|
|
|
|
|
status="published",
|
|
|
|
|
)
|
|
|
|
|
db.session.add(issue)
|
|
|
|
|
db.session.commit()
|
|
|
|
|
issue_id = issue.id
|
|
|
|
|
|
|
|
|
|
with patch("src.routes.issues.requests.post") as mock_post:
|
|
|
|
|
with patch.object(config, "GRIMMORY_URL", "http://grimmory.test"), \
|
|
|
|
|
patch.object(config, "GRIMMORY_USERNAME", "admin"), \
|
|
|
|
|
patch.object(config, "GRIMMORY_PASSWORD", "password"):
|
|
|
|
|
|
|
|
|
|
resp = client.post(f"/issues/{issue_id}/push")
|
|
|
|
|
|
|
|
|
|
assert resp.status_code in (302, 303)
|
|
|
|
|
mock_post.assert_not_called()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_push_issue_login_error(app, client, db, tmp_path):
|
|
|
|
|
"""Test push handles login errors gracefully."""
|
|
|
|
|
os.makedirs(tmp_path, exist_ok=True)
|
|
|
|
|
epub_path = tmp_path / "test.epub"
|
|
|
|
|
epub_path.write_text("dummy epub content")
|
|
|
|
|
|
|
|
|
|
with app.app_context():
|
|
|
|
|
issue = Issue(
|
|
|
|
|
week_start=date(2026, 4, 6),
|
|
|
|
|
week_end=date(2026, 4, 12),
|
|
|
|
|
cover_method="mosaic",
|
|
|
|
|
cover_path=str(tmp_path / "cover.jpg"),
|
|
|
|
|
epub_path=str(epub_path),
|
|
|
|
|
article_ids=json.dumps([1]),
|
|
|
|
|
status="published",
|
|
|
|
|
)
|
|
|
|
|
db.session.add(issue)
|
|
|
|
|
db.session.commit()
|
|
|
|
|
issue_id = issue.id
|
|
|
|
|
|
|
|
|
|
mock_login_resp = MagicMock()
|
|
|
|
|
mock_login_resp.status_code = 401
|
|
|
|
|
mock_login_resp.text = "Unauthorized"
|
|
|
|
|
|
|
|
|
|
with patch("src.routes.issues.requests.post", return_value=mock_login_resp) as mock_post_call:
|
|
|
|
|
with patch.object(config, "GRIMMORY_URL", "http://grimmory.test"), \
|
|
|
|
|
patch.object(config, "GRIMMORY_USERNAME", "admin"), \
|
|
|
|
|
patch.object(config, "GRIMMORY_PASSWORD", "wrong"):
|
|
|
|
|
|
|
|
|
|
resp = client.post(f"/issues/{issue_id}/push")
|
|
|
|
|
|
|
|
|
|
assert resp.status_code in (302, 303)
|
|
|
|
|
assert mock_post_call.call_count == 1
|
|
|
|
|
|
|
|
|
|
login_args, _ = mock_post_call.call_args_list[0]
|
|
|
|
|
assert login_args[0] == "http://grimmory.test/api/v1/auth/login"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_push_issue_api_error(app, client, db, tmp_path):
|
|
|
|
|
"""Test push handles API errors gracefully."""
|
|
|
|
|
os.makedirs(tmp_path, exist_ok=True)
|
|
|
|
|
epub_path = tmp_path / "test.epub"
|
|
|
|
|
epub_path.write_text("dummy epub content")
|
|
|
|
|
|
|
|
|
|
with app.app_context():
|
|
|
|
|
issue = Issue(
|
|
|
|
|
week_start=date(2026, 4, 6),
|
|
|
|
|
week_end=date(2026, 4, 12),
|
|
|
|
|
cover_method="mosaic",
|
|
|
|
|
cover_path=str(tmp_path / "cover.jpg"),
|
|
|
|
|
epub_path=str(epub_path),
|
|
|
|
|
article_ids=json.dumps([1]),
|
|
|
|
|
status="published",
|
|
|
|
|
)
|
|
|
|
|
db.session.add(issue)
|
|
|
|
|
db.session.commit()
|
|
|
|
|
issue_id = issue.id
|
|
|
|
|
|
|
|
|
|
mock_login_resp = MagicMock()
|
|
|
|
|
mock_login_resp.status_code = 200
|
|
|
|
|
mock_login_resp.json.return_value = {"token": "test_token"}
|
|
|
|
|
|
|
|
|
|
mock_upload_resp = MagicMock()
|
|
|
|
|
mock_upload_resp.status_code = 500
|
|
|
|
|
mock_upload_resp.text = "Internal Server Error"
|
|
|
|
|
|
|
|
|
|
def mock_post(url, *args, **kwargs):
|
|
|
|
|
if url.endswith("/api/v1/auth/login"):
|
|
|
|
|
return mock_login_resp
|
|
|
|
|
elif url.endswith("/api/v1/files/upload"):
|
|
|
|
|
return mock_upload_resp
|
|
|
|
|
return MagicMock(status_code=404)
|
|
|
|
|
|
|
|
|
|
def mock_get(url, *args, **kwargs):
|
|
|
|
|
return MagicMock(status_code=200, json=lambda: [{"id": 123, "name": "lib123", "paths": [{"id": 456}]}])
|
|
|
|
|
|
|
|
|
|
with patch("src.routes.issues.requests.post", side_effect=mock_post) as mock_post_call, \
|
|
|
|
|
patch("src.routes.issues.requests.get", side_effect=mock_get) as mock_get_call:
|
|
|
|
|
with patch.object(config, "GRIMMORY_URL", "http://grimmory.test"), \
|
|
|
|
|
patch.object(config, "GRIMMORY_USERNAME", "admin"), \
|
|
|
|
|
patch.object(config, "GRIMMORY_PASSWORD", "password"), \
|
|
|
|
|
patch.object(config, "GRIMMORY_LIBRARY_ID", "lib123"):
|
|
|
|
|
|
|
|
|
|
resp = client.post(f"/issues/{issue_id}/push")
|
|
|
|
|
|
|
|
|
|
assert resp.status_code in (302, 303)
|
|
|
|
|
assert mock_post_call.call_count == 2
|