Add mass editor
All checks were successful
Test and build Docker image / docker (push) Successful in 29s

This commit is contained in:
Joakim Hellsén 2026-07-18 19:29:42 +02:00
commit 64a116d947
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
4 changed files with 1325 additions and 3 deletions

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import concurrent.futures
import io import io
import json import json
import logging import logging
@ -15,6 +16,7 @@ from datetime import datetime
from functools import lru_cache from functools import lru_cache
from html import escape from html import escape
from html import unescape from html import unescape
from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Annotated from typing import Annotated
from typing import TypedDict from typing import TypedDict
@ -61,6 +63,7 @@ from discord_rss_bot.custom_message import get_message_username
from discord_rss_bot.custom_message import replace_tags_in_text_message from discord_rss_bot.custom_message import replace_tags_in_text_message
from discord_rss_bot.custom_message import save_embed from discord_rss_bot.custom_message import save_embed
from discord_rss_bot.feeds import FeedUpdateError from discord_rss_bot.feeds import FeedUpdateError
from discord_rss_bot.feeds import JsonValue
from discord_rss_bot.feeds import SentWebhookRecord from discord_rss_bot.feeds import SentWebhookRecord
from discord_rss_bot.feeds import coerce_media_gallery_image_limit from discord_rss_bot.feeds import coerce_media_gallery_image_limit
from discord_rss_bot.feeds import coerce_webhook_text_length_limit from discord_rss_bot.feeds import coerce_webhook_text_length_limit
@ -91,12 +94,15 @@ from discord_rss_bot.git_backup import commit_state_change
from discord_rss_bot.git_backup import get_backup_path from discord_rss_bot.git_backup import get_backup_path
from discord_rss_bot.is_url_valid import is_url_valid from discord_rss_bot.is_url_valid import is_url_valid
from discord_rss_bot.search import create_search_context from discord_rss_bot.search import create_search_context
from discord_rss_bot.settings import data_dir
from discord_rss_bot.settings import default_custom_embed
from discord_rss_bot.settings import default_custom_message
from discord_rss_bot.settings import get_reader from discord_rss_bot.settings import get_reader
from discord_rss_bot.settings import make_app_reader
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from collections.abc import Iterable from collections.abc import Iterable
from pathlib import Path
from reader.types import JSONType from reader.types import JSONType
@ -2649,12 +2655,439 @@ async def manual_backup(
return RedirectResponse(url=f"/?message={urllib.parse.quote(message)}", status_code=303) return RedirectResponse(url=f"/?message={urllib.parse.quote(message)}", status_code=303)
def _get_grouped_feeds(reader: Reader) -> list[dict[str, typing.Any]]:
"""Build a list of webhook groups with pre-computed indices for template use.
Each group dict contains:
name: The webhook name (or "Orphaned (no webhook)").
group_idx: 1-based index for the group.
feeds: List of dicts with:
feed: The Feed object.
feed_idx: 1-based index within the group.
Returns:
list[dict]: Grouped feeds with pre-computed indices for template rendering.
"""
hooks: list[dict[str, str]] = cast("list[dict[str, str]]", list(reader.get_tag((), "webhooks", [])))
feeds_by_webhook: dict[str, list[Feed]] = {}
orphaned: list[Feed] = []
for feed in reader.get_feeds():
feed_webhook: str = str(reader.get_tag(feed.url, "webhook", ""))
hook_name: str = ""
for hook in hooks:
if hook["url"] == feed_webhook:
hook_name = hook["name"]
break
if hook_name:
feeds_by_webhook.setdefault(hook_name, []).append(feed)
else:
orphaned.append(feed)
grouped: list[dict[str, typing.Any]] = []
for group_idx, (name, feed_list) in enumerate(feeds_by_webhook.items(), start=1):
grouped.append({
"name": name,
"group_idx": group_idx,
"feeds": [{"feed": f, "feed_idx": idx} for idx, f in enumerate(feed_list, start=1)],
})
if orphaned:
grouped.append({
"name": "Orphaned (no webhook)",
"group_idx": len(grouped) + 1,
"feeds": [{"feed": f, "feed_idx": idx} for idx, f in enumerate(orphaned, start=1)],
})
return grouped
@app.get("/mass", response_class=HTMLResponse)
async def get_mass(
request: Request,
reader: Annotated[Reader, Depends(get_reader_dependency)],
active_tab: str = "create",
) -> HTMLResponse:
"""Mass operations page: create, delete, or modify feeds in bulk.
Args:
request: The request object.
reader: The Reader instance.
active_tab: The active tab (create, delete, modify).
Returns:
HTMLResponse: The mass operations page.
"""
hooks: list[dict[str, str]] = cast("list[dict[str, str]]", list(reader.get_tag((), "webhooks", [])))
context: dict[str, typing.Any] = {
"request": request,
"webhooks": hooks,
"all_feeds_grouped": _get_grouped_feeds(reader),
"active_tab": active_tab,
}
return templates.TemplateResponse(request=request, name="mass.html", context=context)
def _create_and_tag_feed(reader: Reader, feed_url: str, webhook_url: str) -> str | None:
"""Add a feed and set its tags, without updating.
Returns:
The feed URL on success, or None if adding failed.
"""
clean_url: str = feed_url.strip()
try:
reader.add_feed(clean_url)
except FeedExistsError:
pass
except ReaderError:
return None
reader.set_tag(clean_url, "webhook", webhook_url) # pyright: ignore[reportArgumentType]
reader.set_tag(clean_url, "save_sent_webhooks", True) # pyright: ignore[reportArgumentType]
reader.set_tag(clean_url, "media_gallery_image_limit", cast("JSONType", 1))
global_webhook_text_length_limit: int = coerce_webhook_text_length_limit(
cast("JsonValue", reader.get_tag((), "webhook_text_length_limit", 4000)), # pyright: ignore[reportArgumentType]
)
reader.set_tag(clean_url, "webhook_text_length_limit", cast("JSONType", global_webhook_text_length_limit))
reader.set_tag(clean_url, "custom_message", default_custom_message) # pyright: ignore[reportArgumentType]
global_screenshot_layout: str = str(reader.get_tag((), "screenshot_layout", "desktop")).strip().lower()
if global_screenshot_layout not in {"desktop", "mobile"}:
global_screenshot_layout = "desktop"
reader.set_tag(clean_url, "screenshot_layout", global_screenshot_layout) # pyright: ignore[reportArgumentType]
global_delivery_mode: str = str(reader.get_tag((), "delivery_mode", "embed")).strip().lower()
if global_delivery_mode not in {"embed", "text"}:
global_delivery_mode = "embed"
reader.set_tag(clean_url, "delivery_mode", global_delivery_mode) # pyright: ignore[reportArgumentType]
reader.set_tag(clean_url, "should_send_embed", global_delivery_mode == "embed") # pyright: ignore[reportArgumentType]
reader.set_tag(clean_url, "embed", json.dumps(default_custom_embed)) # pyright: ignore[reportArgumentType]
return clean_url
def _modify_single_feed( # ruff:ignore[complex-structure, too-many-branches, too-many-statements]
reader: Reader,
url: str,
modify_action: str,
modify_value: str,
) -> dict[str, typing.Any]:
"""Apply a modification action to a single feed and return the result.
Args:
reader: The Reader instance.
url: The feed URL to modify.
modify_action: The action to perform.
modify_value: The value for the action.
Returns:
dict: Result with url, success, error, and action_taken keys.
"""
result: dict[str, typing.Any] = {
"url": url,
"success": False,
"error": "",
"action_taken": "",
}
try:
reader.get_feed(url)
except FeedNotFoundError:
result["error"] = "Feed not found"
return result
if modify_action == "pause":
reader.disable_feed_updates(url)
result["success"] = True
result["action_taken"] = "Paused"
elif modify_action == "unpause":
reader.enable_feed_updates(url)
result["success"] = True
result["action_taken"] = "Unpaused"
elif modify_action == "change_webhook":
webhooks: list[dict[str, str]] = cast("list[dict[str, str]]", list(reader.get_tag((), "webhooks", [])))
webhook_url: str = ""
for hook in webhooks:
if hook["name"] == modify_value:
webhook_url = hook["url"]
break
if webhook_url:
reader.set_tag(url, "webhook", webhook_url) # pyright: ignore[reportArgumentType]
result["success"] = True
result["action_taken"] = f"Webhook changed to {modify_value}"
else:
result["error"] = f"Webhook '{modify_value}' not found"
elif modify_action == "delivery_mode":
if modify_value == "embed":
reader.set_tag(url, "delivery_mode", "embed") # pyright: ignore[reportArgumentType]
reader.set_tag(url, "should_send_embed", True) # pyright: ignore[reportArgumentType]
result["success"] = True
result["action_taken"] = "Delivery mode set to embed"
elif modify_value == "text":
reader.set_tag(url, "delivery_mode", "text") # pyright: ignore[reportArgumentType]
reader.set_tag(url, "should_send_embed", False) # pyright: ignore[reportArgumentType]
result["success"] = True
result["action_taken"] = "Delivery mode set to text"
elif modify_value == "screenshot_desktop":
reader.set_tag(url, "delivery_mode", "screenshot") # pyright: ignore[reportArgumentType]
reader.set_tag(url, "screenshot_layout", "desktop") # pyright: ignore[reportArgumentType]
result["success"] = True
result["action_taken"] = "Delivery mode set to screenshot (desktop)"
elif modify_value == "screenshot_mobile":
reader.set_tag(url, "delivery_mode", "screenshot") # pyright: ignore[reportArgumentType]
reader.set_tag(url, "screenshot_layout", "mobile") # pyright: ignore[reportArgumentType]
result["success"] = True
result["action_taken"] = "Delivery mode set to screenshot (mobile)"
else:
result["error"] = f"Unknown delivery mode: {modify_value}"
elif modify_action == "screenshot_layout":
if modify_value in {"desktop", "mobile"}:
reader.set_tag(url, "screenshot_layout", modify_value) # pyright: ignore[reportArgumentType]
result["success"] = True
result["action_taken"] = f"Screenshot layout set to {modify_value}"
else:
result["error"] = f"Unknown layout: {modify_value}"
elif modify_action == "update_interval":
try:
interval: int = int(modify_value)
except ValueError as e:
result["error"] = str(e)
return result
if interval < 1:
result["error"] = "Interval must be at least 1 minute"
else:
reader.set_tag(url, ".reader.update", {"interval": interval}) # pyright: ignore[reportArgumentType]
result["success"] = True
result["action_taken"] = f"Update interval set to {interval} minute(s)" # ruff:ignore[hardcoded-sql-expression]
else:
result["error"] = f"Unknown action: {modify_action}"
return result
def _update_and_mark_read(db_path: Path, feed_url: str) -> tuple[str, bool, str]:
"""Update a feed and mark entries as read, in its own reader instance.
Called from worker threads to parallelize HTTP fetches.
Returns:
(feed_url, success, error_message)
"""
worker_reader: Reader = make_app_reader(db_path)
try:
worker_reader.update_feed(feed_url)
for entry in worker_reader.get_entries(feed=feed_url, read=False):
worker_reader.set_entry_read(entry, True)
except ReaderError as e:
logger.warning("Failed to update feed %s: %s", feed_url, e)
return feed_url, False, str(e)[:200]
except Exception as e:
logger.exception("Unexpected error updating feed %s", feed_url)
return feed_url, False, str(e)[:200]
finally:
worker_reader.close()
return feed_url, True, ""
@app.post("/mass/create", response_class=HTMLResponse)
async def post_mass_create( # ruff:ignore[complex-structure]
request: Request,
feed_urls: Annotated[str, Form()],
webhook_dropdown: Annotated[str, Form()],
reader: Annotated[Reader, Depends(get_reader_dependency)],
) -> HTMLResponse:
"""Create multiple feeds at once.
Phase 1: Add feeds and set tags (sequential, fast, no HTTP).
Phase 2: Update feeds in parallel via a thread pool.
Args:
request: The request object.
feed_urls: Feed URLs (one per line).
webhook_dropdown: The webhook to attach feeds to.
reader: The Reader instance.
Returns:
HTMLResponse: The mass operations page with results.
Raises:
HTTPException: If the selected webhook is not found.
"""
urls: list[str] = [url.strip() for url in feed_urls.strip().split("\n") if url.strip()]
results: list[dict[str, typing.Any]] = []
webhooks: list[dict[str, str]] = cast("list[dict[str, str]]", list(reader.get_tag((), "webhooks", [])))
# Resolve webhook name to URL once for all feeds
webhook_url: str = ""
for hook in webhooks:
if hook["name"] == webhook_dropdown:
webhook_url = hook["url"]
break
if not webhook_url:
raise HTTPException(status_code=404, detail="Webhook not found")
# Phase 1: Add feeds and set tags (sequential, fast)
urls_to_update: list[str] = []
for url in urls:
added_url: str | None = _create_and_tag_feed(reader, url, webhook_url)
if added_url:
urls_to_update.append(added_url)
results.append({"url": url, "success": False, "feed_url": None, "error": ""})
continue
results.append({"url": url, "success": False, "feed_url": None, "error": "Failed to add feed"})
# Phase 2: Update feeds in parallel
if urls_to_update:
db_path: Path = Path(data_dir) / "db.sqlite"
max_workers: int = min(10, len(urls_to_update))
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_url = {executor.submit(_update_and_mark_read, db_path, u): u for u in urls_to_update}
for future in concurrent.futures.as_completed(future_to_url):
original_url: str = future_to_url[future]
feed_url, update_success, error_msg = future.result()
# Find and update the matching result entry
for result in results:
if result["url"] == original_url:
result["success"] = update_success
result["feed_url"] = feed_url if update_success else None
result["error"] = error_msg
break
reader.update_search()
success_count: int = sum(1 for r in results if r["success"])
if success_count > 0:
commit_state_change(reader, f"Mass create {success_count} feed(s)")
context: dict[str, typing.Any] = {
"request": request,
"webhooks": webhooks,
"all_feeds_grouped": _get_grouped_feeds(reader),
"active_tab": "create",
"feed_urls": feed_urls,
"selected_webhook": webhook_dropdown,
"create_results": results,
"message": f"Created {success_count} of {len(results)} feed(s).",
}
return templates.TemplateResponse(request=request, name="mass.html", context=context)
@app.post("/mass/delete", response_class=HTMLResponse)
async def post_mass_delete(
request: Request,
reader: Annotated[Reader, Depends(get_reader_dependency)],
feed_urls: Annotated[list[str] | None, Form()] = None,
) -> HTMLResponse:
"""Delete multiple feeds at once.
Args:
request: The request object.
reader: The Reader instance.
feed_urls: List of feed URLs to delete.
Returns:
HTMLResponse: The mass operations page with results.
"""
if feed_urls is None:
feed_urls = []
results: list[dict[str, typing.Any]] = []
for url in feed_urls:
result: dict[str, typing.Any] = {"url": url, "success": False, "error": ""}
try:
reader.delete_feed(url)
result["success"] = True
except FeedNotFoundError:
result["error"] = "Feed not found"
except Exception as e: # ruff:ignore[blind-except]
result["error"] = str(e)[:200]
results.append(result)
deleted_count: int = sum(1 for r in results if r["success"])
if deleted_count > 0:
commit_state_change(reader, f"Mass delete {deleted_count} feed(s)")
webhooks: list[dict[str, str]] = cast("list[dict[str, str]]", list(reader.get_tag((), "webhooks", [])))
failed_count: int = len(results) - deleted_count
context: dict[str, typing.Any] = {
"request": request,
"webhooks": webhooks,
"all_feeds_grouped": _get_grouped_feeds(reader),
"active_tab": "delete",
"delete_results": results,
"delete_summary": {"deleted": deleted_count, "failed": failed_count},
"message": f"Deleted {deleted_count} feed(s). {failed_count} failed.",
}
return templates.TemplateResponse(request=request, name="mass.html", context=context)
@app.post("/mass/modify", response_class=HTMLResponse)
async def post_mass_modify(
request: Request,
reader: Annotated[Reader, Depends(get_reader_dependency)],
feed_urls: Annotated[list[str] | None, Form()] = None,
modify_action: Annotated[str, Form()] = "",
modify_value: Annotated[str, Form()] = "",
) -> HTMLResponse:
"""Modify multiple feeds at once.
Args:
request: The request object.
reader: The Reader instance.
feed_urls: List of feed URLs to modify.
modify_action: The action to perform (pause, unpause, change_webhook, etc.).
modify_value: The value for the action.
Returns:
HTMLResponse: The mass operations page with results.
"""
feed_urls_list: list[str] = feed_urls if feed_urls is not None else []
results: list[dict[str, typing.Any]] = []
for url in feed_urls_list:
try:
result = _modify_single_feed(reader, url, modify_action, modify_value)
except Exception as e:
logger.exception("Failed to modify feed %s", url)
result = {"url": url, "success": False, "error": str(e)[:200], "action_taken": ""}
results.append(result)
modified_count: int = sum(1 for r in results if r["success"])
failed_count: int = sum(1 for r in results if not r["success"])
if modified_count > 0:
commit_state_change(reader, f"Mass modify {modified_count} feed(s)")
webhooks = cast("list[dict[str, str]]", list(reader.get_tag((), "webhooks", [])))
context: dict[str, typing.Any] = {
"request": request,
"webhooks": webhooks,
"all_feeds_grouped": _get_grouped_feeds(reader),
"active_tab": "modify",
"modify_results": results,
"modify_action": modify_action,
"modify_value": modify_value,
"modify_summary": {
"modified": modified_count,
"failed": failed_count,
"skipped": 0,
},
"message": f"Modified {modified_count} feed(s). {failed_count} failed.",
}
return templates.TemplateResponse(request=request, name="mass.html", context=context)
@app.get("/search", response_class=HTMLResponse) @app.get("/search", response_class=HTMLResponse)
async def search( async def search(
request: Request, request: Request,
query: str, query: str,
reader: Annotated[Reader, Depends(get_reader_dependency)], reader: Annotated[Reader, Depends(get_reader_dependency)],
): ) -> HTMLResponse:
"""Get entries matching a full-text search query. """Get entries matching a full-text search query.
Args: Args:

View file

@ -0,0 +1,355 @@
{% extends "base.html" %}
{% block title %}
Mass operations | discord-rss-bot
{% endblock title %}
{% block description %}
Mass create, delete, or modify RSS feeds in bulk.
{% endblock description %}
{% block content %}
{% if message %}
<div class="alert alert-info alert-dismissible fade show" role="alert">
<pre>{{ message }}</pre>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<ul class="nav nav-tabs nav-dark border-dark mb-3" id="massTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link text-muted {% if active_tab == 'create' %}active bg-dark text-light{% endif %}"
id="create-tab"
data-bs-toggle="tab"
data-bs-target="#create"
type="button"
role="tab"
aria-controls="create"
aria-selected="{% if active_tab == 'create' %}true{% else %}false{% endif %}">
Mass Create
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link text-muted {% if active_tab == 'delete' %}active bg-dark text-light{% endif %}"
id="delete-tab"
data-bs-toggle="tab"
data-bs-target="#delete"
type="button"
role="tab"
aria-controls="delete"
aria-selected="{% if active_tab == 'delete' %}true{% else %}false{% endif %}">
Mass Delete
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link text-muted {% if active_tab == 'modify' %}active bg-dark text-light{% endif %}"
id="modify-tab"
data-bs-toggle="tab"
data-bs-target="#modify"
type="button"
role="tab"
aria-controls="modify"
aria-selected="{% if active_tab == 'modify' %}true{% else %}false{% endif %}">
Mass Modify
</button>
</li>
</ul>
<div class="tab-content" id="massTabsContent">
{# ===== Mass Create ===== #}
<div class="tab-pane fade {% if active_tab == 'create' %}show active{% endif %}"
id="create"
role="tabpanel"
aria-labelledby="create-tab">
<div class="card border border-dark p-3 text-light">
<h3 class="h5">Create multiple feeds</h3>
<p class="text-muted small mb-3">Enter one feed URL per line. All feeds will be attached to the selected webhook.</p>
<form action="/mass/create" method="post">
<div class="mb-3">
<label for="feed_urls" class="form-label">Feed URLs (one per line)</label>
<textarea name="feed_urls"
id="feed_urls"
class="form-control bg-dark border-dark text-muted"
rows="10"
placeholder="https://example.com/feed.xml&#10;https://other.com/rss&#10;https://third.com/atom">{{ feed_urls or '' }}</textarea>
</div>
<div class="mb-3">
<label for="webhook_dropdown" class="form-label">Webhook</label>
<select class="form-select bg-dark border-dark text-muted"
id="webhook_dropdown"
name="webhook_dropdown"
required>
<option value="" {% if not selected_webhook %}selected{% endif %} disabled>Choose webhook...</option>
{% for hook in webhooks %}
<option value="{{ hook.name }}"
{% if hook.name == selected_webhook %}selected{% endif %}>{{ hook.name }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary">Create all feeds</button>
</form>
</div>
{% if create_results %}
<div class="card border border-dark p-3 text-light mt-3">
<h3 class="h5">Results</h3>
<ul class="list-unstyled mb-0">
{% for result in create_results %}
<li class="mb-2 {% if result.success %}text-success{% else %}text-danger{% endif %}">
<strong>{{ result.url }}</strong>
{% if result.success %}
&check; Added
{% if result.feed_url %}
- <a class="text-success" href="/feed?feed_url={{ result.feed_url|encode_url }}">View feed</a>
{% endif %}
{% else %}
&cross; {{ result.error }}
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{# ===== Mass Delete ===== #}
<div class="tab-pane fade {% if active_tab == 'delete' %}show active{% endif %}"
id="delete"
role="tabpanel"
aria-labelledby="delete-tab">
<div class="card border border-dark p-3 text-light">
<h3 class="h5">Delete multiple feeds</h3>
<p class="text-muted small mb-3">Select feeds to delete. This action cannot be undone.</p>
{% if all_feeds_grouped %}
<form id="mass-delete-form" action="/mass/delete" method="post">
{% for group in all_feeds_grouped %}
<div class="mb-3 border border-dark p-2">
<div class="d-flex justify-content-between align-items-center mb-2">
<h4 class="h6 mb-0">{{ group.name }} ({{ group.feeds|length }})</h4>
<div>
<button type="button"
class="btn btn-outline-light btn-sm select-all"
data-group="{{ group.group_idx }}">Select all</button>
<button type="button"
class="btn btn-outline-secondary btn-sm deselect-all"
data-group="{{ group.group_idx }}">Deselect all</button>
</div>
</div>
<ul class="list-unstyled mb-0">
{% for item in group.feeds %}
{% set feed = item.feed %}
<li class="form-check">
<input class="form-check-input mass-delete-checkbox group-{{ group.group_idx }}"
type="checkbox"
name="feed_urls"
value="{{ feed.url }}"
id="delete-{{ group.group_idx }}-{{ item.feed_idx }}" />
<label class="form-check-label text-muted" for="delete-{{ group.group_idx }}-{{ item.feed_idx }}">
{% if feed.title %}
{{ feed.title }}
{% else %}
{{ feed.url }}
{% endif %}
{% if feed.title %}<span class="small">- {{ feed.url }}</span>{% endif %}
{% if not feed.updates_enabled %}<span class="text-warning small">Paused</span>{% endif %}
{% if feed.last_exception %}<span class="text-danger small">Error</span>{% endif %}
</label>
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
<button type="submit"
class="btn btn-danger"
onclick="return confirm('Are you sure you want to delete the selected feeds? This cannot be undone.');">
Delete selected feeds
</button>
</form>
{% else %}
<p class="text-muted mb-0">No feeds found.</p>
{% endif %}
</div>
{% if delete_results %}
<div class="card border border-dark p-3 text-light mt-3">
<h3 class="h5">Results</h3>
<ul class="list-unstyled mb-0">
{% for result in delete_results %}
<li class="mb-2 {% if result.success %}text-success{% else %}text-danger{% endif %}">
<strong>{{ result.url }}</strong>
{% if result.success %}
&check; Deleted
{% else %}
&cross; {{ result.error }}
{% endif %}
</li>
{% endfor %}
<li class="mt-2 text-muted">{{ delete_summary.deleted }} deleted, {{ delete_summary.failed }} failed</li>
</ul>
</div>
{% endif %}
</div>
{# ===== Mass Modify ===== #}
<div class="tab-pane fade {% if active_tab == 'modify' %}show active{% endif %}"
id="modify"
role="tabpanel"
aria-labelledby="modify-tab">
<div class="card border border-dark p-3 text-light">
<h3 class="h5">Modify multiple feeds</h3>
<p class="text-muted small mb-3">Select feeds and choose a setting to update.</p>
{% if all_feeds_grouped %}
<form id="mass-modify-form" action="/mass/modify" method="post">
{% for group in all_feeds_grouped %}
<div class="mb-3 border border-dark p-2">
<div class="d-flex justify-content-between align-items-center mb-2">
<h4 class="h6 mb-0">{{ group.name }} ({{ group.feeds|length }})</h4>
<div>
<button type="button"
class="btn btn-outline-light btn-sm select-all"
data-group="modify-{{ group.group_idx }}">Select all</button>
<button type="button"
class="btn btn-outline-secondary btn-sm deselect-all"
data-group="modify-{{ group.group_idx }}">Deselect all</button>
</div>
</div>
<ul class="list-unstyled mb-0">
{% for item in group.feeds %}
{% set feed = item.feed %}
<li class="form-check">
<input class="form-check-input mass-modify-checkbox group-modify-{{ group.group_idx }}"
type="checkbox"
name="feed_urls"
value="{{ feed.url }}"
id="modify-{{ group.group_idx }}-{{ item.feed_idx }}" />
<label class="form-check-label text-muted"
for="modify-{{ group.group_idx }}-{{ item.feed_idx }}">
{% if feed.title %}
{{ feed.title }}
{% else %}
{{ feed.url }}
{% endif %}
{% if feed.title %}<span class="small">- {{ feed.url }}</span>{% endif %}
{% if not feed.updates_enabled %}<span class="text-warning small">Paused</span>{% endif %}
</label>
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
<hr class="border-secondary" />
<div class="row g-3">
<div class="col-md-4">
<label for="modify_action" class="form-label">Action</label>
<select class="form-select bg-dark border-dark text-muted"
id="modify_action"
name="modify_action"
required>
<option value="" disabled {% if not modify_action %}selected{% endif %}>Choose action...</option>
<option value="pause" {% if modify_action == 'pause' %}selected{% endif %}>Pause (disable updates)</option>
<option value="unpause" {% if modify_action == 'unpause' %}selected{% endif %}>Unpause (enable updates)</option>
<option value="change_webhook" {% if modify_action == 'change_webhook' %}selected{% endif %}>Change webhook</option>
<option value="delivery_mode" {% if modify_action == 'delivery_mode' %}selected{% endif %}>Change delivery mode</option>
<option value="screenshot_layout" {% if modify_action == 'screenshot_layout' %}selected{% endif %}>Change screenshot layout</option>
<option value="update_interval" {% if modify_action == 'update_interval' %}selected{% endif %}>Change update interval (minutes)</option>
</select>
</div>
<div class="col-md-4" id="modify-value-group">
<label for="modify_value" class="form-label">Value</label>
<div id="modify-value-dynamic">
<input type="text"
name="modify_value"
id="modify_value"
class="form-control bg-dark border-dark text-muted"
value="{{ modify_value or '' }}"
placeholder="New value" />
</div>
</div>
</div>
<button type="submit" class="btn btn-warning mt-3">Apply to selected feeds</button>
</form>
{% else %}
<p class="text-muted mb-0">No feeds found.</p>
{% endif %}
</div>
{% if modify_results %}
<div class="card border border-dark p-3 text-light mt-3">
<h3 class="h5">Results</h3>
<ul class="list-unstyled mb-0">
{% for result in modify_results %}
<li class="mb-2 {% if result.success %}text-success{% else %}text-danger{% endif %}">
<strong>{{ result.url }}</strong>
{% if result.success %}
&check; {{ result.action_taken }}
{% else %}
&cross; {{ result.error }}
{% endif %}
</li>
{% endfor %}
<li class="mt-2 text-muted">
{{ modify_summary.modified }} modified, {{ modify_summary.failed }} failed, {{ modify_summary.skipped }} skipped
</li>
</ul>
</div>
{% endif %}
</div>
</div>
{% endblock content %}
{% block head %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Select/Deselect all for mass delete and modify checkboxes
document.querySelectorAll('.select-all').forEach(function(btn) {
btn.addEventListener('click', function() {
var group = this.getAttribute('data-group');
document.querySelectorAll('.group-' + group).forEach(function(cb) {
cb.checked = true;
});
});
});
document.querySelectorAll('.deselect-all').forEach(function(btn) {
btn.addEventListener('click', function() {
var group = this.getAttribute('data-group');
document.querySelectorAll('.group-' + group).forEach(function(cb) {
cb.checked = false;
});
});
});
// Dynamic value field for modify action
var actionSelect = document.getElementById('modify_action');
var valueDynamic = document.getElementById('modify-value-dynamic');
if (actionSelect && valueDynamic) {
function updateModifyValueField() {
var action = actionSelect.value;
var html = '';
if (action === 'pause' || action === 'unpause') {
html = '<p class="text-muted small mt-2">No additional value needed.</p>';
} else if (action === 'change_webhook') {
html = `<select name="modify_value" class="form-select bg-dark border-dark text-muted" required>
<option value="" disabled selected>Choose webhook...</option>
{% for hook in webhooks %}
<option value="{{ hook.name }}">{{ hook.name }}</option>
{% endfor %}
</select>`;
} else if (action === 'delivery_mode') {
html = `<select name="modify_value" class="form-select bg-dark border-dark text-muted" required>
<option value="" disabled selected>Choose mode...</option>
<option value="embed">Embed</option>
<option value="text">Text</option>
<option value="screenshot_desktop">Screenshot (Desktop)</option>
<option value="screenshot_mobile">Screenshot (Mobile)</option>
</select>`;
} else if (action === 'screenshot_layout') {
html = `<select name="modify_value" class="form-select bg-dark border-dark text-muted" required>
<option value="" disabled selected>Choose layout...</option>
<option value="desktop">Desktop</option>
<option value="mobile">Mobile</option>
</select>`;
} else if (action === 'update_interval') {
html = '<input type="number" name="modify_value" class="form-control bg-dark border-dark text-muted" required min="1" value="15" placeholder="Minutes" />';
} else {
html = '<input type="text" name="modify_value" class="form-control bg-dark border-dark text-muted" placeholder="Value" />';
}
valueDynamic.innerHTML = html;
}
actionSelect.addEventListener('change', updateModifyValueField);
// Init
if (actionSelect.value) {
updateModifyValueField();
}
}
});
</script>
{% endblock head %}

View file

@ -18,6 +18,10 @@
</li> </li>
<li class="nav-item nav-link d-none d-md-block">|</li> <li class="nav-item nav-link d-none d-md-block">|</li>
{% endif %} {% endif %}
<li class="nav-item">
<a class="nav-link" href="/mass">Mass ops</a>
</li>
<li class="nav-item nav-link d-none d-md-block">|</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/webhooks">Webhooks</a> <a class="nav-link" href="/webhooks">Webhooks</a>
</li> </li>

View file

@ -14,8 +14,10 @@ from typing import cast
from unittest.mock import MagicMock from unittest.mock import MagicMock
from unittest.mock import patch from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from reader import FeedExistsError from reader import FeedExistsError
from reader import FeedNotFoundError
import discord_rss_bot.main as main_module import discord_rss_bot.main as main_module
from discord_rss_bot import feeds from discord_rss_bot import feeds
@ -26,7 +28,6 @@ from discord_rss_bot.main import get_reader_dependency
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
import pytest
from httpx2 import Response from httpx2 import Response
from reader import Entry from reader import Entry
from reader import Reader from reader import Reader
@ -3355,3 +3356,532 @@ def test_post_set_custom_clears_username_and_avatar() -> None:
assert not stub.set_tag.call_args_list[1].args[2] assert not stub.set_tag.call_args_list[1].args[2]
finally: finally:
app.dependency_overrides = {} app.dependency_overrides = {}
# ---------------------------------------------------------------------------
# Mass operations tests
# ---------------------------------------------------------------------------
class _StubFeedForMass:
"""Minimal feed stub for mass operations tests."""
def __init__(self, url: str, title: str | None = None, updates_enabled: bool = True) -> None: # ruff:ignore[boolean-default-value-positional-argument, boolean-type-hint-positional-argument]
self.url: str = url
self.title: str | None = title
self.updates_enabled: bool = updates_enabled
self.last_exception: str | None = None
class _StubReaderForMass:
"""Stub reader with feeds grouped under a webhook, used by mass operations tests."""
def __init__(
self,
feeds: list[_StubFeedForMass] | None = None,
*,
webhook_name: str = "",
webhook_url: str = "",
delete_fail: bool = False,
) -> None:
self._feeds: list[_StubFeedForMass] = feeds or []
self._webhook_name: str = webhook_name
self._webhook_url: str = webhook_url
self._delete_fail: bool = delete_fail
self.set_tag_calls: list[tuple[str, str, object]] = []
self.disabled_feeds: set[str] = set()
self.enabled_feeds: set[str] = set()
def get_feeds(self) -> list[_StubFeedForMass]:
"""Return the list of feeds."""
return self._feeds
def get_tag(self, resource: tuple[()] | str, key: str, default: TestTagValue = None) -> TestTagValue: # ruff:ignore[too-many-return-statements]
"""Return stored tag values."""
if resource == () and key == "webhooks":
if self._webhook_name:
return [{"name": self._webhook_name, "url": self._webhook_url}]
return []
if resource == () and key == "delivery_mode":
return "embed"
if resource == () and key == "webhook_text_length_limit":
return 4000
if resource == () and key == "screenshot_layout":
return "desktop"
if isinstance(resource, str) and key == "webhook":
return self._webhook_url
return default
def set_tag(self, resource: str, key: str, value: object) -> None: # pyright: ignore[reportArgumentType]
"""Record tag set calls."""
self.set_tag_calls.append((resource, key, value))
def update_search(self) -> None:
"""No-op search index update."""
def delete_feed(self, url: str) -> None:
"""Delete a feed by URL.
Raises:
RuntimeError: If delete_fail was set in the constructor.
"""
if self._delete_fail:
msg = "Delete failed"
raise RuntimeError(msg)
self._feeds = [f for f in self._feeds if f.url != url]
def get_feed(self, url: str) -> _StubFeedForMass:
"""Get a feed by URL.
Returns:
The matching feed.
Raises:
FeedNotFoundError: If no feed matches the URL.
"""
for feed in self._feeds:
if feed.url == url:
return feed
raise FeedNotFoundError(url)
def disable_feed_updates(self, url: str) -> None:
"""Disable updates for a feed."""
self.disabled_feeds.add(url)
self.enabled_feeds.discard(url)
def enable_feed_updates(self, url: str) -> None:
"""Enable updates for a feed."""
self.enabled_feeds.add(url)
self.disabled_feeds.discard(url)
def add_feed(self, url: str) -> None:
"""Add a feed to the stub."""
self._feeds.append(_StubFeedForMass(url=url))
MASS_FEED_1_URL: str = "https://example.com/feed1.xml"
MASS_FEED_2_URL: str = "https://example.com/feed2.xml"
MASS_WEBHOOK_NAME: str = "Mass test webhook"
MASS_WEBHOOK_URL: str = "https://discord.com/api/webhooks/mass/abc"
@pytest.fixture
def mass_stub_reader() -> _StubReaderForMass:
"""Create a stub reader with feeds for mass operations tests.
Returns:
A configured stub reader instance.
"""
return _StubReaderForMass(
feeds=[
_StubFeedForMass(url=MASS_FEED_1_URL, title="Feed One"),
_StubFeedForMass(url=MASS_FEED_2_URL, title="Feed Two", updates_enabled=False),
],
webhook_name=MASS_WEBHOOK_NAME,
webhook_url=MASS_WEBHOOK_URL,
)
class TestMassPage:
"""Tests for the mass operations page."""
def test_mass_page_loads(self, mass_stub_reader: _StubReaderForMass) -> None:
"""GET /mass should render all three tabs."""
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
try:
response: Response = client.get("/mass")
assert response.status_code == 200
assert "Mass Create" in response.text
assert "Mass Delete" in response.text
assert "Mass Modify" in response.text
finally:
app.dependency_overrides = {}
def test_mass_page_shows_feeds_when_webhook_exists(self, mass_stub_reader: _StubReaderForMass) -> None:
"""GET /mass should list feeds grouped by webhook."""
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
try:
response: Response = client.get("/mass")
assert response.status_code == 200
assert MASS_WEBHOOK_NAME in response.text
assert "Feed One" in response.text
assert "Feed Two" in response.text
finally:
app.dependency_overrides = {}
def test_mass_page_no_feeds_shows_empty_state(self) -> None:
"""GET /mass with no feeds should show 'No feeds found'."""
stub = _StubReaderForMass(webhook_name=MASS_WEBHOOK_NAME, webhook_url=MASS_WEBHOOK_URL)
app.dependency_overrides[get_reader_dependency] = lambda: stub
try:
response: Response = client.get("/mass")
assert response.status_code == 200
assert "No feeds found" in response.text
finally:
app.dependency_overrides = {}
def test_mass_page_defaults_to_create_tab(self, mass_stub_reader: _StubReaderForMass) -> None:
"""GET /mass without active_tab should default to create tab."""
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
try:
response: Response = client.get("/mass")
assert response.status_code == 200
assert 'id="create-tab"' in response.text
assert 'aria-selected="true"' in response.text
finally:
app.dependency_overrides = {}
def test_mass_page_respects_active_tab_param(self, mass_stub_reader: _StubReaderForMass) -> None:
"""GET /mass?active_tab=delete should show delete tab active."""
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
try:
response: Response = client.get("/mass?active_tab=delete")
assert response.status_code == 200
assert "Delete multiple feeds" in response.text
finally:
app.dependency_overrides = {}
def test_mass_page_shows_webhooks_in_create_form(self, mass_stub_reader: _StubReaderForMass) -> None:
"""The mass create tab should list webhooks in the dropdown."""
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
try:
response: Response = client.get("/mass")
assert response.status_code == 200
assert MASS_WEBHOOK_NAME in response.text
assert "Create all feeds" in response.text
finally:
app.dependency_overrides = {}
class TestMassDelete:
"""Tests for POST /mass/delete."""
def test_delete_selected_feeds(self, mass_stub_reader: _StubReaderForMass) -> None:
"""POST /mass/delete with feed URLs should delete them."""
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
try:
with patch("discord_rss_bot.main.commit_state_change"):
response: Response = client.post(
"/mass/delete",
data={"feed_urls": [MASS_FEED_1_URL, MASS_FEED_2_URL]},
)
assert response.status_code == 200
assert "Deleted 2 feed(s)" in response.text
finally:
app.dependency_overrides = {}
def test_delete_no_selection(self, mass_stub_reader: _StubReaderForMass) -> None:
"""POST /mass/delete with no feed URLs should not crash."""
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
try:
response: Response = client.post("/mass/delete")
assert response.status_code == 200
assert "Deleted 0 feed(s)" in response.text
finally:
app.dependency_overrides = {}
def test_delete_feed_error_does_not_crash(self) -> None:
"""POST /mass/delete should handle reader errors gracefully."""
stub = _StubReaderForMass(
feeds=[_StubFeedForMass(url=MASS_FEED_1_URL)],
delete_fail=True,
)
app.dependency_overrides[get_reader_dependency] = lambda: stub
try:
response: Response = client.post("/mass/delete", data={"feed_urls": [MASS_FEED_1_URL]})
assert response.status_code == 200
assert "0 deleted, 1 failed" in response.text
finally:
app.dependency_overrides = {}
class TestMassModify:
"""Tests for POST /mass/modify."""
def test_modify_pause_feed(self, mass_stub_reader: _StubReaderForMass) -> None:
"""POST /mass/modify with pause action should disable feed updates."""
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
try:
with patch("discord_rss_bot.main.commit_state_change"):
response: Response = client.post(
"/mass/modify",
data={
"feed_urls": [MASS_FEED_1_URL],
"modify_action": "pause",
"modify_value": "",
},
)
assert response.status_code == 200
assert MASS_FEED_1_URL in response.text
assert "Modified 1 feed(s)" in response.text
assert MASS_FEED_1_URL in mass_stub_reader.disabled_feeds
finally:
app.dependency_overrides = {}
def test_modify_unpause_feed(self, mass_stub_reader: _StubReaderForMass) -> None:
"""POST /mass/modify with unpause action should enable feed updates."""
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
try:
with patch("discord_rss_bot.main.commit_state_change"):
response: Response = client.post(
"/mass/modify",
data={
"feed_urls": [MASS_FEED_2_URL],
"modify_action": "unpause",
"modify_value": "",
},
)
assert response.status_code == 200
assert "Modified 1 feed(s)" in response.text
assert MASS_FEED_2_URL in mass_stub_reader.enabled_feeds
finally:
app.dependency_overrides = {}
def test_modify_nonexistent_feed(self, mass_stub_reader: _StubReaderForMass) -> None:
"""POST /mass/modify for a non-existent feed should report error."""
nonexistent = "https://example.com/nonexistent.xml"
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
try:
response: Response = client.post(
"/mass/modify",
data={
"feed_urls": [nonexistent],
"modify_action": "pause",
"modify_value": "",
},
)
assert response.status_code == 200
assert "0 modified, 1 failed" in response.text
assert "Feed not found" in response.text
finally:
app.dependency_overrides = {}
def test_modify_unknown_action(self, mass_stub_reader: _StubReaderForMass) -> None:
"""POST /mass/modify with an unknown action should report error."""
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
try:
response: Response = client.post(
"/mass/modify",
data={
"feed_urls": [MASS_FEED_1_URL],
"modify_action": "nonexistent_action",
"modify_value": "",
},
)
assert response.status_code == 200
assert "0 modified, 1 failed" in response.text
assert "Unknown action" in response.text
finally:
app.dependency_overrides = {}
def test_modify_change_webhook_updates_tag(self, mass_stub_reader: _StubReaderForMass) -> None:
"""POST /mass/modify with change_webhook should call set_tag with the new webhook URL."""
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
try:
with patch("discord_rss_bot.main.commit_state_change"):
response: Response = client.post(
"/mass/modify",
data={
"feed_urls": [MASS_FEED_1_URL],
"modify_action": "change_webhook",
"modify_value": MASS_WEBHOOK_NAME,
},
)
assert response.status_code == 200
assert "Modified 1 feed(s)" in response.text
# Check that set_tag was called with the webhook tag
webhook_calls = [c for c in mass_stub_reader.set_tag_calls if c[1] == "webhook"]
assert len(webhook_calls) == 1
assert webhook_calls[0][2] == MASS_WEBHOOK_URL
finally:
app.dependency_overrides = {}
def test_modify_unknown_webhook_reports_error(self, mass_stub_reader: _StubReaderForMass) -> None:
"""POST /mass/modify with a non-existent webhook name should report error."""
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
try:
response: Response = client.post(
"/mass/modify",
data={
"feed_urls": [MASS_FEED_1_URL],
"modify_action": "change_webhook",
"modify_value": "Nonexistent Hook",
},
)
assert response.status_code == 200
assert "0 modified, 1 failed" in response.text
assert "not found" in response.text
finally:
app.dependency_overrides = {}
def test_modify_delivery_mode_embed(self, mass_stub_reader: _StubReaderForMass) -> None:
"""POST /mass/modify with delivery_mode=embed should set delivery_mode and should_send_embed."""
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
try:
with patch("discord_rss_bot.main.commit_state_change"):
response: Response = client.post(
"/mass/modify",
data={
"feed_urls": [MASS_FEED_1_URL],
"modify_action": "delivery_mode",
"modify_value": "embed",
},
)
assert response.status_code == 200
assert "Modified 1 feed(s)" in response.text
delivery_calls = [c for c in mass_stub_reader.set_tag_calls if c[1] == "delivery_mode"]
embed_calls = [c for c in mass_stub_reader.set_tag_calls if c[1] == "should_send_embed"]
assert len(delivery_calls) == 1
assert delivery_calls[0][2] == "embed"
assert len(embed_calls) == 1
assert embed_calls[0][2] is True
finally:
app.dependency_overrides = {}
def test_modify_delivery_mode_text(self, mass_stub_reader: _StubReaderForMass) -> None:
"""POST /mass/modify with delivery_mode=text should set should_send_embed to False."""
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
try:
with patch("discord_rss_bot.main.commit_state_change"):
response: Response = client.post(
"/mass/modify",
data={
"feed_urls": [MASS_FEED_1_URL],
"modify_action": "delivery_mode",
"modify_value": "text",
},
)
assert response.status_code == 200
assert "Modified 1 feed(s)" in response.text
embed_calls = [c for c in mass_stub_reader.set_tag_calls if c[1] == "should_send_embed"]
assert len(embed_calls) == 1
assert embed_calls[0][2] is False
finally:
app.dependency_overrides = {}
def test_modify_update_interval(self, mass_stub_reader: _StubReaderForMass) -> None:
"""POST /mass/modify with update_interval should store the interval."""
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
try:
with patch("discord_rss_bot.main.commit_state_change"):
response: Response = client.post(
"/mass/modify",
data={
"feed_urls": [MASS_FEED_1_URL],
"modify_action": "update_interval",
"modify_value": "30",
},
)
assert response.status_code == 200
assert "Modified 1 feed(s)" in response.text
update_calls = [c for c in mass_stub_reader.set_tag_calls if c[1] == ".reader.update"]
assert len(update_calls) == 1
assert update_calls[0][2] == {"interval": 30}
finally:
app.dependency_overrides = {}
def test_modify_update_interval_invalid(self, mass_stub_reader: _StubReaderForMass) -> None:
"""POST /mass/modify with non-numeric update_interval should report error."""
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
try:
response: Response = client.post(
"/mass/modify",
data={
"feed_urls": [MASS_FEED_1_URL],
"modify_action": "update_interval",
"modify_value": "not-a-number",
},
)
assert response.status_code == 200
assert "0 modified, 1 failed" in response.text
finally:
app.dependency_overrides = {}
def test_modify_no_selection(self, mass_stub_reader: _StubReaderForMass) -> None:
"""POST /mass/modify with no feed URLs should not crash."""
app.dependency_overrides[get_reader_dependency] = lambda: mass_stub_reader
try:
response: Response = client.post(
"/mass/modify",
data={"modify_action": "pause", "modify_value": ""},
)
assert response.status_code == 200
assert "Modified 0 feed(s)" in response.text
finally:
app.dependency_overrides = {}
class TestMassCreate:
"""Tests for POST /mass/create."""
def test_create_rejects_missing_webhook(self) -> None:
"""POST /mass/create without a valid webhook should return 404."""
stub = _StubReaderForMass()
app.dependency_overrides[get_reader_dependency] = lambda: stub
try:
response: Response = client.post(
"/mass/create",
data={
"feed_urls": "https://example.com/feed.xml",
"webhook_dropdown": "nonexistent",
},
)
assert response.status_code == 404
assert "Webhook not found" in response.text
finally:
app.dependency_overrides = {}
def test_create_single_url(self) -> None:
"""POST /mass/create with a single URL should attempt to create it."""
stub = _StubReaderForMass(webhook_name=MASS_WEBHOOK_NAME, webhook_url=MASS_WEBHOOK_URL)
app.dependency_overrides[get_reader_dependency] = lambda: stub
try:
with (
patch("discord_rss_bot.main._update_and_mark_read") as mock_update,
patch("discord_rss_bot.main.commit_state_change"),
):
mock_update.return_value = ("https://example.com/new-feed.xml", True, "")
response: Response = client.post(
"/mass/create",
data={
"feed_urls": "https://example.com/new-feed.xml",
"webhook_dropdown": MASS_WEBHOOK_NAME,
},
)
assert response.status_code == 200
finally:
app.dependency_overrides = {}
def test_create_multiple_urls(self) -> None:
"""POST /mass/create with multiple URLs should attempt to create them."""
stub = _StubReaderForMass(webhook_name=MASS_WEBHOOK_NAME, webhook_url=MASS_WEBHOOK_URL)
app.dependency_overrides[get_reader_dependency] = lambda: stub
try:
with (
patch("discord_rss_bot.main._update_and_mark_read") as mock_update,
patch("discord_rss_bot.main.commit_state_change"),
):
mock_update.side_effect = [
("https://example.com/feed1.xml", True, ""),
("https://example.com/feed2.xml", True, ""),
]
response: Response = client.post(
"/mass/create",
data={
"feed_urls": "https://example.com/feed1.xml\nhttps://example.com/feed2.xml",
"webhook_dropdown": MASS_WEBHOOK_NAME,
},
)
assert response.status_code == 200
finally:
app.dependency_overrides = {}
class TestMassNav:
"""Tests for the mass operations navigation link."""
def test_mass_link_in_navbar(self) -> None:
"""The navbar should contain a link to the mass operations page."""
response: Response = client.get("/")
assert response.status_code == 200
assert "/mass" in response.text, f"Expected /mass link in navbar: {response.text}"