Compare commits

...

5 Commits

Author SHA1 Message Date
a12bd41890 chore: bump version 2026-08-16 19:55:41 +02:00
a7ed2c999c added delete option 2026-08-16 19:53:38 +02:00
46e8b2c9eb chore: bump version 2026-07-31 10:41:06 +02:00
ec24325036 chore: bump version 2026-07-31 10:40:18 +02:00
4ec95d8ba9 chore: bump version 2026-07-31 09:53:41 +02:00
25 changed files with 3268 additions and 6 deletions

View File

@@ -0,0 +1,661 @@
# Plan: Delete Anime Feature
## Feature Summary
Add a right-click context menu option on anime series cards to delete an anime. Provides three deletion modes: database-only, folder-only, or both. Requires the user to type "delete" in a confirmation field.
---
## 1. Backend — Database Layer
### 1.1 `src/server/database/service.py` — Add `delete` method to `AnimeSeriesService`
```python
@staticmethod
async def delete(db: AsyncSession, series_id: int) -> bool:
"""Hard-delete an anime series and all its episodes/queue items.
Uses cascade delete configured on the AnimeSeries model
(cascade="all, delete-orphan" on episodes and download_items).
Args:
db: Database session
series_id: Primary key (id) of the AnimeSeries to delete
Returns:
True if a row was deleted, False if not found
Raises:
AnimeServiceError: On database errors
"""
```
**Implementation notes:**
- Uses `select(delete(...).where(...))` pattern matching existing codebase style
- Logs series key + name before deletion for audit trail
- Returns `True`/`False` (not an exception) when series not found — caller decides response code
- Wraps in try/except, logs error, re-raises as `AnimeServiceError`
### 1.2 `src/server/database/service.py` — Add `get_folder_path` helper to `AnimeSeriesService`
```python
@staticmethod
async def get_folder_path(db: AsyncSession, series_key: str) -> str | None:
"""Get the filesystem folder path for a series by its key.
Args:
db: Database session
series_key: Provider key (e.g. "attack-on-titan")
Returns:
Folder path string, or None if series not found
"""
```
---
## 2. Backend — AnimeService
### 2.1 `src/server/services/anime_service.py` — Add `delete_series` method
```python
async def delete_series(
self,
key: str,
delete_database: bool = True,
delete_folder: bool = False,
) -> DeleteSeriesResult:
"""Delete an anime series from DB, filesystem, or both.
Args:
key: Series key (primary identifier)
delete_database: If True, remove from database (default True)
delete_folder: If True, remove folder from filesystem (default False)
Returns:
DeleteSeriesResult with success status, what was deleted, errors
Raises:
AnimeServiceError: On critical failures
"""
```
**`DeleteSeriesResult` Pydantic model (add to `src/server/models/anime.py`):**
```python
class DeleteSeriesResult(BaseModel):
success: bool
key: str
name: str
deleted_from_database: bool
deleted_folder: bool
folder_path: str | None
database_error: str | None
folder_error: str | None
message: str
```
**Step-by-step inside `delete_series`:**
1. **Log start**`logger.info("Delete series requested key=%s db=%s folder=%s", key, delete_database, delete_folder)`
2. **Fetch series from DB** to get `id`, `folder`, `name`
3. **If `delete_database=True`**:
a. Call `AnimeSeriesService.delete(db, series_id)`
b. Log success/failure
c. Invalidate `_cached_list_missing` LRU cache
d. Broadcast `series_deleted` WebSocket event
4. **If `delete_folder=True`**:
a. Validate folder path with `is_safe_path(self._directory, folder_path)` — reject if outside base directory
b. Use `shutil.rmtree(folder_path)` to delete the folder
c. Log success/failure
5. **Log completion**`logger.info("Delete series completed key=%s results=%s", key, result)`
6. Return `DeleteSeriesResult`
**Error handling:**
- DB errors during folder delete → log but don't fail the whole operation
- Folder delete errors → log, attach to result, don't rollback DB delete
- Series not found → return `DeleteSeriesResult(success=False, message="Series not found")`
### 2.2 `src/server/services/anime_service.py` — Add `broadcast_series_deleted`
```python
async def _broadcast_series_deleted(self, key: str, name: str) -> None:
"""Broadcast series_deleted event via WebSocket."""
```
Mirrors existing `_broadcast_series_updated` pattern.
---
## 3. Backend — API Layer
### 3.1 `src/server/api/anime.py` — Add request/response models
```python
class DeleteSeriesRequest(BaseModel):
"""Request payload for DELETE /api/anime/{key}."""
delete_database: bool = Field(
default=True,
description="Whether to remove the series from the database"
)
delete_folder: bool = Field(
default=False,
description="Whether to delete the series folder from filesystem"
)
confirm_text: str = Field(
...,
description="Must be exactly 'delete' to confirm"
)
class DeleteSeriesResponse(BaseModel):
"""Response payload for DELETE /api/anime/{key}."""
success: bool
key: str
name: str
deleted_from_database: bool
deleted_folder: bool
folder_path: str | None
database_error: str | None
folder_error: str | None
message: str
```
### 3.2 `src/server/api/anime.py` — Add DELETE endpoint
```python
@router.delete("/{key}", response_model=DeleteSeriesResponse)
async def delete_anime(
key: str,
request: DeleteSeriesRequest,
_auth: dict = Depends(require_auth),
anime_service: AnimeService = Depends(get_anime_service),
) -> DeleteSeriesResponse:
"""Delete an anime series from database, filesystem, or both.
Requires typing 'delete' in the confirm_text field to prevent accidents.
Args:
key: Series key (from URL path)
request: DeleteSeriesRequest with options and confirmation
_auth: Ensures the caller is authenticated
anime_service: AnimeService instance
Returns:
DeleteSeriesResponse with outcome details
Raises:
HTTPException(400): If confirm_text is not exactly 'delete'
HTTPException(404): If series not found
HTTPException(500): On unexpected errors
"""
```
**Validation:**
- `confirm_text` must be exactly `"delete"` (case-insensitive? No — exact match to be strict)
- If `delete_folder=True` but folder doesn't exist → log warning, continue with DB delete
- If neither `delete_database` nor `delete_folder` is True → return 400
### 3.3 Update constants — No changes needed to `constants.js` for the endpoint path
The existing `API.ANIME_LIST` pattern is `/api/anime` — DELETE `/api/anime/{key}` follows REST conventions.
---
## 4. Frontend — Context Menu
### 4.1 `src/server/web/static/js/index/context-menu.js` — Add delete option
```javascript
// In the show() function, add a divider and delete option:
menuElement.innerHTML = `
<div class="context-menu-item" data-action="anime-settings">
<i class="fa-solid fa-gear"></i>
<span>Anime Settings</span>
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item danger" data-action="delete-anime">
<i class="fa-solid fa-trash"></i>
<span>Delete Anime</span>
</div>
`;
// Handler for delete-anime:
menuElement.querySelector('[data-action="delete-anime"]').addEventListener('click', function() {
const key = currentSeriesKey;
hide();
AniWorld.DeleteModal && AniWorld.DeleteModal.show(key);
});
```
### 4.2 Create `src/server/web/static/js/index/delete-modal.js`
New module for the confirmation modal. Module structure mirrors `anime-settings.js`.
**Features:**
- `show(key)` — opens modal with series info populated
- `hide()` — closes and resets modal
- Shows series name and key being deleted
- Three checkboxes: `☐ Remove from database` (default checked), `☐ Delete folder` (default unchecked)
- Confirmation text field: user must type exactly `delete`
- Delete button: disabled until confirmation text matches
- Error display area
- Keyboard: Escape closes, Enter submits if valid
**Modal HTML structure** (inline in JS, no new HTML file needed):
```html
<div id="delete-modal" class="modal hidden">
<div class="modal-backdrop"></div>
<div class="modal-content">
<h2>Delete Anime</h2>
<p id="delete-modal-series-name"></p>
<p id="delete-modal-series-key"></p>
<label>
<input type="checkbox" id="delete-db-checkbox" checked>
Remove from database (recommended)
</label>
<label>
<input type="checkbox" id="delete-folder-checkbox">
Delete folder from filesystem
</label>
<p class="warning">This will permanently delete the folder and all its files!</p>
<label for="delete-confirm-input">
Type <strong>delete</strong> to confirm:
</label>
<input type="text" id="delete-confirm-input" placeholder="delete">
<div id="delete-error" class="error-message hidden"></div>
<div class="modal-actions">
<button id="delete-cancel-btn">Cancel</button>
<button id="delete-confirm-btn" disabled>Delete</button>
</div>
</div>
</div>
```
**CSS** — add to existing CSS files:
- `.context-menu-item.danger { color: var(--color-danger, #dc3545); }`
- `.context-menu-divider { height: 1px; background: var(--color-border); margin: 4px 0; }`
- `.warning { color: var(--color-warning, #ffc107); font-size: 0.875em; }`
- Modal styles (`.modal`, `.modal-backdrop`, `.modal-content`) — reuse existing modal CSS if present, or add new
**API call on confirm:**
```javascript
const response = await AniWorld.ApiClient.request(`/api/anime/${encodeURIComponent(key)}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
delete_database: document.getElementById('delete-db-checkbox').checked,
delete_folder: document.getElementById('delete-folder-checkbox').checked,
confirm_text: document.getElementById('delete-confirm-input').value
})
});
```
**On success:**
- Show success toast
- Close modal
- Reload series grid (or remove card from DOM directly)
**On error:**
- Show error message in modal
### 4.3 `src/server/web/static/js/index/app-init.js` — Initialize delete modal
Add initialization call:
```javascript
if (AniWorld.DeleteModal) {
AniWorld.DeleteModal.init();
}
```
---
## 5. WebSocket Event
### 5.1 `src/server/services/websocket_service.py` — Add `broadcast_series_deleted`
```python
async def broadcast_series_deleted(self, key: str, name: str) -> None:
"""Broadcast a series_deleted event.
Args:
key: Series key that was deleted
name: Series name for display
"""
```
### 5.2 `src/server/web/static/js/index/socket-handler.js` — Handle `series_deleted`
Handle the new WebSocket event to remove the deleted card from the UI in real-time:
```javascript
case AniWorld.Constants.WS_EVENTS.SERIES_DELETED:
if (AniWorld.SeriesManager) {
AniWorld.SeriesManager.removeSeries(data.key);
}
break;
```
### 5.3 `src/server/web/static/js/shared/constants.js` — Add event constant
```javascript
SERIES_DELETED: 'series_deleted',
```
---
## 6. Logging
### 6.1 Backend logging points (all use Python `logging.getLogger(__name__)`):
| Event | Level | Message format |
|-------|-------|----------------|
| Delete requested | `info` | `"Delete series requested: key=%s delete_database=%s delete_folder=%s"` |
| Series not found | `warning` | `"Delete series failed - not found: key=%s"` |
| DB delete success | `info` | `"Deleted series from database: key=%s name=%s id=%d"` |
| DB delete failure | `error` | `"Failed to delete series from database: key=%s error=%s"` |
| Folder delete start | `info` | `"Deleting series folder: key=%s path=%s"` |
| Folder delete success | `info` | `"Deleted series folder: key=%s path=%s"` |
| Folder delete failure | `error` | `"Failed to delete series folder: key=%s path=%s error=%s"` |
| Path traversal blocked | `warning` | `"Blocked unsafe folder delete attempt: key=%s path=%s base=%s"` |
| Delete completed | `info` | `"Delete series completed: key=%s db=%s folder=%s"` |
### 6.2 Frontend logging points (console.log / console.error):
| Event | Level |
|-------|-------|
| Delete modal opened | `console.info` |
| Delete API call initiated | `console.info` |
| Delete success | `console.info` |
| Delete API error | `console.error` |
| Validation failure (confirm_text) | `console.warn` |
---
## 7. Documentation
### 7.1 Create `docs/delete-anime-feature.md`
```markdown
# Delete Anime Feature
## Overview
Allows authenticated users to delete an anime series from the database,
filesystem, or both via the right-click context menu on series cards.
## Safety Mechanisms
### Confirmation Required
Users must type exactly `delete` in a confirmation field before deletion
proceeds. This prevents accidental clicks.
### Selective Deletion
Two independent options:
- **Remove from database**: Removes the series and all its episodes/queue
items from the SQLite database. The folder on disk is preserved.
- **Delete folder**: Removes the series folder and ALL files inside it
from the filesystem. This cannot be undone.
### Path Traversal Protection
Folder deletion validates the target path is within the configured
`directory_to_search` base directory before attempting deletion.
## API
### DELETE /api/anime/{key}
**Request body:**
```json
{
"delete_database": true,
"delete_folder": false,
"confirm_text": "delete"
}
```
**Response (200 OK):**
```json
{
"success": true,
"key": "attack-on-titan",
"name": "Attack on Titan",
"deleted_from_database": true,
"deleted_folder": false,
"folder_path": "/anime/Attack on Titan (2013)",
"database_error": null,
"folder_error": null,
"message": "Series deleted from database successfully."
}
```
**Error responses:**
- `400 Bad Request`: confirm_text != "delete", or neither delete option selected
- `401 Unauthorized`: Missing or invalid auth token
- `404 Not Found`: Series key does not exist
- `500 Internal Server Error`: Unexpected error
## Events
### WebSocket: series_deleted
Broadcast to all connected clients when a series is deleted.
**Payload:**
```json
{
"event": "series_deleted",
"key": "attack-on-titan",
"name": "Attack on Titan"
}
```
## Permissions
Requires authentication. Only authenticated users can delete anime.
```
---
## 8. Tests
### 8.1 Unit Tests — `tests/unit/test_anime_service.py`
Add new test class:
```python
class TestDeleteSeries:
"""Test delete_series operation."""
@pytest.mark.asyncio
async def test_delete_database_only_success(self, anime_service, mock_series_app):
"""Test deleting a series from database only."""
# Setup: create mock series in DB
# Assert: delete_database=True, delete_folder=False
# Assert: cache invalidated
# Assert: WebSocket broadcast called
@pytest.mark.asyncio
async def test_delete_folder_only(self, anime_service, mock_series_app, tmp_path):
"""Test deleting only the folder."""
# Setup: create series with folder on disk
# Assert: folder deleted from filesystem
# Assert: DB record still exists
@pytest.mark.asyncio
async def test_delete_both(self, anime_service, mock_series_app, tmp_path):
"""Test deleting both DB record and folder."""
@pytest.mark.asyncio
async def test_delete_nonexistent(self, anime_service):
"""Test deleting a series that doesn't exist returns success=False."""
@pytest.mark.asyncio
async def test_delete_folder_path_traversal_blocked(self, anime_service):
"""Test that path traversal attempts are blocked and logged."""
@pytest.mark.asyncio
async def test_delete_folder_not_found_continues(self, anime_service, mock_series_app):
"""Test that missing folder doesn't fail the DB delete."""
@pytest.mark.asyncio
async def test_delete_invalid_confirm_text(self, anime_service):
"""Test API rejects non-matching confirm_text."""
@pytest.mark.asyncio
async def test_delete_no_options_selected(self, anime_service):
"""Test API rejects when neither option is True."""
```
### 8.2 API Endpoint Tests — `tests/api/test_anime_endpoints.py`
Add tests for `DELETE /api/anime/{key}`:
```python
class TestDeleteAnimeEndpoints:
"""Tests for DELETE /api/anime/{key}."""
@pytest.mark.asyncio
async def test_delete_requires_auth(self, client):
"""Test that unauthenticated requests are rejected."""
@pytest.mark.asyncio
async def test_delete_requires_confirm_text(self, client, auth_headers, test_series):
"""Test that missing confirm_text returns 400."""
@pytest.mark.asyncio
async def test_delete_wrong_confirm_text(self, client, auth_headers, test_series):
"""Test that wrong confirm_text returns 400."""
@pytest.mark.asyncio
async def test_delete_database_only(self, client, auth_headers, test_series):
"""Test database-only deletion."""
@pytest.mark.asyncio
async def test_delete_folder_only(self, client, auth_headers, test_series):
"""Test folder-only deletion."""
@pytest.mark.asyncio
async def test_delete_both(self, client, auth_headers, test_series):
"""Test deletion of both DB and folder."""
@pytest.mark.asyncio
async def test_delete_nonexistent_returns_404(self, client, auth_headers):
"""Test deleting non-existent series."""
@pytest.mark.asyncio
async def test_delete_folder_outside_base_rejected(self, client, auth_headers, test_series):
"""Test that path traversal is blocked."""
```
### 8.3 Frontend Tests — `tests/frontend/test_existing_ui_integration.py`
Add integration tests for delete modal:
```javascript
describe('Delete Anime Modal', () => {
it('should open on context menu delete click');
it('should require typing delete to enable button');
it('should call DELETE API on confirm');
it('should show error on API failure');
it('should close on cancel');
it('should close on Escape key');
it('should disable confirm until text matches');
});
```
### 8.4 Security Tests — `tests/security/test_input_validation.py`
```python
class TestDeleteValidation:
"""Security tests for delete endpoint input validation."""
def test_path_traversal_in_folder_delete(self, client, auth_headers):
"""Ensure folder delete cannot escape base directory."""
def test_missing_confirm_text_rejected(self, client, auth_headers):
"""Ensure confirm_text is validated."""
def test_empty_key_rejected(self, client, auth_headers):
"""Ensure empty series key is rejected."""
```
---
## 9. Step-by-Step Implementation Order
### Phase 1: Backend Core
1. **Add `AnimeSeriesService.delete()` and `get_folder_path()`** to `src/server/database/service.py`
2. **Add `DeleteSeriesResult` Pydantic model** to `src/server/models/anime.py`
3. **Add `DeleteSeriesRequest` and `DeleteSeriesResponse`** to `src/server/models/anime.py`
4. **Add `delete_series()` and `_broadcast_series_deleted()`** to `src/server/services/anime_service.py`
5. **Add `broadcast_series_deleted()`** to `src/server/services/websocket_service.py`
6. **Add `DELETE /api/anime/{key}` endpoint** to `src/server/api/anime.py`
7. **Add `SERIES_DELETED` constant** to `src/server/web/static/js/shared/constants.js`
### Phase 2: Frontend
8. **Add CSS styles** for context menu danger item, divider, delete modal
9. **Create `src/server/web/static/js/index/delete-modal.js`** with full modal implementation
10. **Update `src/server/web/static/js/index/context-menu.js`** to add delete option
11. **Update `src/server/web/static/js/index/app-init.js`** to initialize modal
12. **Update `src/server/web/static/js/index/socket-handler.js`** to handle `series_deleted` event
13. **Update `src/server/web/static/js/index/series-manager.js`** — add `removeSeries(key)` method
### Phase 3: Tests
14. **Add unit tests** in `tests/unit/test_anime_service.py`
15. **Add API endpoint tests** in `tests/api/test_anime_endpoints.py`
16. **Add frontend integration tests** in `tests/frontend/`
17. **Add security validation tests** in `tests/security/test_input_validation.py`
### Phase 4: Documentation
18. **Create `docs/delete-anime-feature.md`**
19. **Update `docs/README.md`** or main docs index if it exists
---
## Key Files to Modify
| File | Change Type |
|------|-------------|
| `src/server/database/service.py` | Add 2 methods |
| `src/server/models/anime.py` | Add 2 Pydantic models |
| `src/server/services/anime_service.py` | Add 2 methods |
| `src/server/services/websocket_service.py` | Add 1 method |
| `src/server/api/anime.py` | Add 2 models + 1 endpoint |
| `src/server/web/static/js/shared/constants.js` | Add 1 constant |
| `src/server/web/static/js/index/context-menu.js` | Add menu item + handler |
| `src/server/web/static/js/index/delete-modal.js` | **New file** |
| `src/server/web/static/js/index/app-init.js` | Add init call |
| `src/server/web/static/js/index/socket-handler.js` | Handle new event |
| `src/server/web/static/js/index/series-manager.js` | Add `removeSeries()` |
| CSS files | Add modal/context menu styles |
| `tests/unit/test_anime_service.py` | Add test class |
| `tests/api/test_anime_endpoints.py` | Add endpoint tests |
| `tests/frontend/test_existing_ui_integration.py` | Add frontend tests |
| `tests/security/test_input_validation.py` | Add security tests |
| `docs/delete-anime-feature.md` | **New file** |
---
## Verification Steps
After implementation, verify by running:
```bash
# Backend tests
pytest tests/unit/test_anime_service.py::TestDeleteSeries -v
pytest tests/api/test_anime_endpoints.py::TestDeleteAnimeEndpoints -v
pytest tests/security/test_input_validation.py::TestDeleteValidation -v
# Frontend tests
npm run test
# Manual verification:
# 1. Right-click a series card → "Delete Anime" option appears
# 2. Clicking it opens the confirmation modal
# 3. Without typing "delete", the button is disabled
# 4. Typing "delete" enables the button
# 5. Selecting "Remove from database" and confirming deletes the series
# 6. Selecting "Delete folder" deletes the folder from disk
# 7. Selecting both deletes both DB record and folder
# 8. After deletion, the card is removed from the grid in real-time (WebSocket)
```

View File

@@ -1 +1 @@
v1.5.3
v1.5.6

View File

@@ -368,6 +368,71 @@ Return detailed information about a specific series.
Source: [src/server/api/anime.py](../src/server/api/anime.py#L713-L793)
### DELETE /api/anime/{anime_key}
Delete an anime series from the database, filesystem, or both. Requires
authentication and explicit typed confirmation.
**Authentication:** Required
**Path Parameters:**
| Parameter | Description |
|-----------|-------------|
| `anime_key` | Series key (primary identifier) |
**Request Body:**
```json
{
"delete_database": true,
"delete_folder": false,
"confirm_text": "delete"
}
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `delete_database` | bool | `true` | Remove series and episodes from SQLite |
| `delete_folder` | bool | `false` | Delete the series folder and all files |
| `confirm_text` | string | — | Must be exactly `"delete"` (case-sensitive) |
**Response (200 OK):**
```json
{
"success": true,
"key": "attack-on-titan",
"name": "Attack on Titan",
"deleted_from_database": true,
"deleted_folder": false,
"folder_path": null,
"database_error": null,
"folder_error": null,
"message": "Removed from database."
}
```
**Error Responses:**
| Status | Condition |
|--------|-----------|
| 400 | `confirm_text != "delete"` or neither flag is `true` |
| 401 | Not authenticated |
| 404 | Series `key` not found in database |
| 500 | Unexpected server error |
**Deletion Modes:**
| Flags | Effect |
|-------|--------|
| `delete_database=true, delete_folder=false` | Removes series from SQLite. Folder on disk is preserved. |
| `delete_database=false, delete_folder=true` | Deletes folder and all files. Database record preserved. |
| `delete_database=true, delete_folder=true` | Full removal: database record deleted AND folder/files deleted. |
**Path Safety:** Folder deletion is blocked if the path is outside the configured anime base directory (path traversal protection via `is_safe_path`).
**WebSocket Broadcast:** On success, a `series_deleted` event is broadcast to all connected clients, causing the anime card to be removed from all browser sessions in real-time.
Source: [src/server/api/anime.py](../src/server/api/anime.py#L1759-L1840)
---
## 4. Download Queue Endpoints

View File

@@ -41,6 +41,35 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
### Added
- **Delete Anime Feature** — Right-click on any anime card and select
"Delete Anime" to remove a series. Three modes are available:
database only, folder only, or both. A typed-confirmation
(`delete`) is required to prevent accidental deletions. The
operation is broadcast via WebSocket so all connected clients
remove the card in real-time. Path traversal protection prevents
folder deletion outside the anime base directory.
- `DELETE /api/anime/{key}` endpoint (`src/server/api/anime.py`)
- `AnimeService.delete_series()` orchestrator
(`src/server/services/anime_service.py`)
- `broadcast_series_deleted()` WebSocket broadcast
(`src/server/services/websocket_service.py`)
- `DeleteSeriesRequest` / `DeleteSeriesResult` Pydantic models
(`src/server/models/anime.py`)
- Frontend modal with typed confirmation
(`src/server/web/static/js/index/delete-modal.js`)
- Right-click "Delete Anime" context menu item
(`src/server/web/static/js/index/context-menu.js`)
- `SERIES_DELETED` WebSocket event handling
(`src/server/web/static/js/index/socket-handler.js`)
- `SeriesManager.removeSeries()` grid cleanup
(`src/server/web/static/js/index/series-manager.js`)
- Full test suite:
`tests/unit/test_delete_anime_service.py`,
`tests/api/test_delete_anime_endpoint.py`,
`tests/frontend/test_delete_modal.py`,
`tests/security/test_delete_anime_security.py`
- Documentation: `Docs/DELETE_ANIME_FEATURE.md`
- **Anime Settings page** — renamed from "NFO Diagnostics". Right-click
on any anime card → "Anime Settings" navigates to
`/anime/settings?key=<series>`. The new page lets the user view and

View File

@@ -0,0 +1,179 @@
# Delete Anime Feature
## Overview
The Delete Anime feature allows authenticated users to remove an anime series from the Aniworld library. It supports three deletion modes: **database only**, **folder only**, or **both**. A mandatory typed-confirmation (`delete`) prevents accidental deletions.
---
## Usage
### How to Delete an Anime
1. **Right-click** on any anime series card in the library grid.
2. Select **"Delete Anime"** from the context menu.
3. A confirmation modal appears with two options:
- ☑️ **Remove from database** (recommended) — removes series and episodes from SQLite
-**Delete folder from filesystem** — deletes the folder and all files inside
4. **Type `delete`** in the confirmation text field to enable the Delete button.
5. Click **Delete** to proceed.
### What Gets Deleted
| Option | Effect |
|--------|--------|
| Database only | Series, episodes, and queue entries removed from SQLite. Folder on disk is preserved. Downloaded episode files remain. |
| Folder only | Entire folder and all files inside deleted from filesystem. Database record preserved with `is_downloaded=True`. |
| Both | Full removal: database record deleted AND folder/files deleted from disk. |
---
## Architecture
### Backend Components
| File | Role |
|------|------|
| `src/server/api/anime.py` | `DELETE /api/anime/{key}` endpoint |
| `src/server/services/anime_service.py` | `AnimeService.delete_series()` orchestrator |
| `src/server/services/websocket_service.py` | `broadcast_series_deleted()` for real-time UI updates |
| `src/server/database/service.py` | `AnimeSeriesService.get_folder_path()` + existing `delete()` |
| `src/server/models/anime.py` | `DeleteSeriesRequest` / `DeleteSeriesResult` Pydantic models |
### Frontend Components
| File | Role |
|------|------|
| `src/server/web/static/js/index/delete-modal.js` | Modal UI, confirm text validation, API calls |
| `src/server/web/static/js/index/context-menu.js` | Right-click "Delete Anime" menu item |
| `src/server/web/static/js/index/socket-handler.js` | `SERIES_DELETED` WebSocket event handler |
| `src/server/web/static/js/index/series-manager.js` | `removeSeries(key)` — removes card from grid |
| `src/server/web/static/js/index/app-init.js` | Initializes `DeleteModal` |
| `src/server/web/static/css/components/modals.css` | Modal and context menu styles |
| `src/server/web/templates/index.html` | Loads `delete-modal.js` before `app-init.js` |
### API Endpoint
```
DELETE /api/anime/{key}
```
**Request body:**
```json
{
"delete_database": true,
"delete_folder": false,
"confirm_text": "delete"
}
```
**Success response (200):**
```json
{
"success": true,
"key": "attack-on-titan",
"name": "Attack on Titan",
"deleted_from_database": true,
"deleted_folder": false,
"folder_path": null,
"database_error": null,
"folder_error": null,
"message": "Removed from database."
}
```
**Error responses:**
| Status | Condition |
|--------|-----------|
| 400 | `confirm_text != "delete"` or neither flag is `true` |
| 401 | Not authenticated |
| 404 | Series `key` not found in database |
| 500 | Unexpected server error |
### WebSocket Event
After a successful delete, the server broadcasts a `series_deleted` event:
```json
{
"type": "series_deleted",
"data": {
"key": "attack-on-titan",
"name": "Attack on Titan"
}
}
```
All connected clients remove the card from their grid in real-time.
---
## Safety Mechanisms
### 1. Typed Confirmation
Users must type exactly `delete` (case-sensitive) to unlock the Delete button. This prevents accidental clicks from triggering deletion.
### 2. Path Traversal Protection
Before deleting a folder, `is_safe_path()` validates the path stays within the configured anime base directory. Paths outside this boundary are rejected with a `folder_error`.
### 3. Granular Options
The two independent checkboxes ensure users consciously choose what to delete. Default is **database only** (recommended).
### 4. WebSocket Broadcast
All clients are notified immediately when a series is deleted, keeping multiple browser sessions in sync.
### 5. No Shell Injection
Series keys are never passed to shell commands. All file operations use `pathlib.Path`.
---
## Logging
### Backend Logs (Python/`logging`)
| Event | Level | Message |
|-------|-------|---------|
| Delete initiated | INFO | `Delete anime initiated: key={key} delete_db={x} delete_folder={x}` |
| Series not found | WARNING | `Delete anime failed — series not found: key={key}` |
| Path traversal attempt | WARNING | `Delete anime blocked — path traversal attempt: key={key} path={path}` |
| DB error | ERROR | `Delete anime DB error: key={key} error={message}` |
| Folder delete error | ERROR | `Delete anime folder error: key={key} error={message}` |
| Delete succeeded | INFO | `Delete anime succeeded: key={key} deleted_db={x} deleted_folder={x}` |
### Frontend Logs (JS/`console`)
| Event | Method |
|-------|--------|
| Modal opened | `console.info('[DeleteModal] Opening for key:', key)` |
| Delete confirmed | `console.info('[DeleteModal] Initiating delete:', {...})` |
| Delete succeeded | `console.info('[DeleteModal] Delete succeeded:', result)` |
| API/network error | `console.error('[DeleteModal] Delete request failed:', err)` |
| Series removed from grid | `console.info('[SeriesManager] Removed series from local state:', key)` |
| WS event received | `console.info('[SocketHandler] Series deleted:', data)` |
---
## Configuration
No new configuration options are required. The feature uses existing paths:
- **Anime base directory**: `settings.anime_directory` (for path traversal validation)
- **Database path**: `series_app.database_path` (for DB deletion)
- **Queue cleanup**: `AnimeSeriesService.delete(series_key)` cascades to queue items
---
## Testing
See:
- `tests/unit/test_delete_anime_service.py` — unit tests for `AnimeService.delete_series()`
- `tests/api/test_delete_anime_endpoint.py` — API endpoint tests including auth, validation, error cases
- `tests/frontend/test_delete_modal.py` — frontend modal logic and DOM validation tests
- `tests/security/test_delete_anime_security.py` — security tests for path traversal, XSS, auth bypass
---
## Changelog
| Date | Change |
|------|--------|
| 2026-08-16 | Feature added. `DELETE /api/anime/{key}`, right-click context menu, typed-confirmation modal, WebSocket sync. |

View File

@@ -70,6 +70,7 @@ The application now features a comprehensive configuration system that allows us
- **Library Scanning**: Automated scanning for missing episodes with database persistence
- **Episode Tracking**: Missing episodes tracked in database, automatically updated during scans
- **NFO Status Indicators**: Visual badges showing NFO and media file status for each series
- **Delete Anime**: Right-click any anime card → "Delete Anime" to remove a series from the database, filesystem, or both. Type `delete` in the confirmation field to proceed. See [Delete Anime Feature](./DELETE_ANIME_FEATURE.md) for details.
## NFO Metadata Management

View File

@@ -1,6 +1,6 @@
{
"name": "aniworld-web",
"version": "1.5.3",
"version": "1.5.6",
"description": "Aniworld Anime Download Manager - Web Frontend",
"type": "module",
"scripts": {

View File

@@ -22,6 +22,8 @@ from src.server.models.anime import (
AnimeSettingsRegenerateNfoResponse,
AnimeSettingsResponse,
AnimeSettingsUpdateRequest,
DeleteSeriesRequest,
DeleteSeriesResult,
TMDBSearchResult,
)
from src.server.services.anime_service import AnimeService, AnimeServiceError
@@ -1755,3 +1757,83 @@ async def regenerate_anime_nfo(
nfo_path=db_series.nfo_path,
repaired_tags=repaired_tags,
)
@router.delete(
"/{anime_key}",
response_model=DeleteSeriesResult,
)
async def delete_anime(
anime_key: str,
request: DeleteSeriesRequest,
_auth: dict = Depends(require_auth),
anime_service: AnimeService = Depends(get_anime_service),
) -> DeleteSeriesResult:
"""Delete an anime series from database, filesystem, or both.
Requires typing exactly 'delete' in the confirm_text field to prevent
accidental deletions. Users can choose to remove the series from the
database only, delete the folder only, or both.
Args:
anime_key: Series key from URL path (primary identifier)
request: DeleteSeriesRequest with delete options and confirmation
_auth: Ensures the caller is authenticated
anime_service: AnimeService dependency
Returns:
DeleteSeriesResult with outcome details
Raises:
HTTPException(400): confirm_text != "delete" or no options selected
HTTPException(404): Series not found
HTTPException(500): Unexpected error
"""
# Validate confirm_text
if request.confirm_text != "delete":
logger.warning(
"Delete anime rejected - invalid confirm_text: key=%s",
anime_key,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"Confirmation text must be exactly 'delete'. "
f"Got '{request.confirm_text}'."
),
)
# Validate at least one option is selected
if not request.delete_database and not request.delete_folder:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one of delete_database or delete_folder must be True.",
)
try:
result = await anime_service.delete_series(
key=anime_key,
delete_database=request.delete_database,
delete_folder=request.delete_folder,
)
if not result.success and not result.deleted_from_database:
# This means series was not found (key="" was passed as name)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=result.message,
)
return result
except HTTPException:
raise
except Exception as exc:
logger.error(
"Delete anime failed: key=%s error=%s",
anime_key, str(exc),
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Delete failed: {exc}",
) from exc

View File

@@ -134,7 +134,7 @@ class SerieList:
"""Return all series that still contain missing episodes."""
return [
anime for anime in self.keyDict.values()
if any(not ep.is_downloaded for ep in (anime.episodes or []))
if getattr(anime, 'episodeDict', None)
]
def get_missing_episodes(self) -> List[AnimeSeries]:

View File

@@ -210,6 +210,25 @@ class AnimeSeriesService:
)
return result.scalar_one_or_none()
@staticmethod
async def get_folder_path(
db: AsyncSession,
series_key: str,
) -> Optional[str]:
"""Get the filesystem folder path for a series by its key.
Args:
db: Database session
series_key: Provider key (e.g. "attack-on-titan")
Returns:
Folder path string, or None if series not found
"""
result = await db.execute(
select(AnimeSeries.folder).where(AnimeSeries.key == series_key)
)
return result.scalar_one_or_none()
@staticmethod
async def get_all(
db: AsyncSession,

View File

@@ -295,3 +295,55 @@ class AnimeSettingsRegenerateNfoResponse(BaseModel):
default_factory=list,
description="Tags that were missing before regeneration",
)
class DeleteSeriesRequest(BaseModel):
"""Request payload for DELETE /api/anime/{key}.
Requires typing exactly 'delete' in confirm_text to prevent accidental deletions.
"""
delete_database: bool = Field(
default=True,
description="Whether to remove the series from the database (default: True)"
)
delete_folder: bool = Field(
default=False,
description="Whether to delete the series folder from filesystem (default: False)"
)
confirm_text: str = Field(
...,
description="Must be exactly 'delete' to confirm the operation"
)
class DeleteSeriesResult(BaseModel):
"""Result of a delete operation on a series.
Tracks what was successfully deleted and any errors encountered.
"""
success: bool = Field(..., description="Whether the operation succeeded")
key: str = Field(..., description="Series key that was deleted")
name: str = Field(..., description="Series name at time of deletion")
deleted_from_database: bool = Field(
default=False,
description="Whether the series was removed from the database"
)
deleted_folder: bool = Field(
default=False,
description="Whether the folder was deleted from filesystem"
)
folder_path: Optional[str] = Field(
None,
description="Path to the folder that was (or would be) deleted"
)
database_error: Optional[str] = Field(
None,
description="Error message if database deletion failed"
)
folder_error: Optional[str] = Field(
None,
description="Error message if folder deletion failed"
)
message: str = Field(..., description="Human-readable outcome message")

View File

@@ -1634,6 +1634,200 @@ class AnimeService:
logger.error("Failed to get NFO statistics: %s", str(exc))
raise AnimeServiceError("NFO statistics query failed") from exc
async def delete_series(
self,
key: str,
delete_database: bool = True,
delete_folder: bool = False,
) -> "DeleteSeriesResult":
"""Delete an anime series from database, filesystem, or both.
Args:
key: Series key (primary identifier)
delete_database: If True, remove from database (default True)
delete_folder: If True, remove folder from filesystem (default False)
Returns:
DeleteSeriesResult with success status, what was deleted, errors
"""
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
from src.server.models.anime import DeleteSeriesResult
from src.server.utils.filesystem import is_safe_path
import os as _os
import shutil
logger.info(
"Delete series requested: key=%s delete_database=%s delete_folder=%s",
key, delete_database, delete_folder,
)
# Guard: at least one deletion mode must be selected
if not delete_database and not delete_folder:
logger.warning(
"Delete series rejected - no options selected: key=%s", key
)
return DeleteSeriesResult(
success=False,
key=key,
name="",
folder_path=None,
deleted_from_database=False,
deleted_folder=False,
database_error=None,
folder_error=None,
message="At least one of delete_database or delete_folder must be True.",
)
# Single DB session for fetch + optional delete
async with get_db_session() as db:
series = await AnimeSeriesService.get_by_key(db, key)
if not series:
logger.warning(
"Delete series failed - not found: key=%s", key
)
return DeleteSeriesResult(
success=False,
key=key,
name="",
folder_path=None,
deleted_from_database=False,
deleted_folder=False,
database_error=None,
folder_error=None,
message=f"Series '{key}' not found.",
)
series_id = series.id
series_name = series.name
folder_path = series.folder
result = DeleteSeriesResult(
success=True,
key=key,
name=series_name,
folder_path=folder_path,
message="",
)
# --- Database deletion ---
if delete_database:
try:
async with get_db_session() as db:
deleted = await AnimeSeriesService.delete(db, series_id)
if deleted:
logger.info(
"Deleted series from database: key=%s name=%s id=%d",
key, series_name, series_id,
)
result.deleted_from_database = True
else:
# Already gone is treated as success
result.deleted_from_database = True
except Exception as exc:
logger.error(
"Failed to delete series from database: key=%s error=%s",
key, str(exc),
)
result.database_error = str(exc)
result.success = False
# Invalidate list cache
try:
self._cached_list_missing.cache_clear()
except Exception: # pylint: disable=broad-except
pass
# Broadcast deletion via WebSocket
try:
await self._broadcast_series_deleted(key, series_name)
except Exception as exc:
logger.warning(
"Failed to broadcast series_deleted for %s: %s",
key, exc,
)
# --- Filesystem deletion ---
if delete_folder and folder_path:
# Resolve absolute path and validate it is within base directory
abs_folder = _os.path.abspath(folder_path)
base_dir = _os.path.abspath(self._directory)
if not is_safe_path(base_dir, abs_folder):
logger.warning(
"Blocked unsafe folder delete attempt: key=%s path=%s base=%s",
key, abs_folder, base_dir,
)
result.folder_error = (
f"Path '{abs_folder}' is outside the anime directory "
f"'{base_dir}' and will not be deleted."
)
result.success = False
elif not _os.path.isdir(abs_folder):
logger.warning(
"Delete folder skipped - path does not exist: key=%s path=%s",
key, abs_folder,
)
# Not an error; folder might never have existed
else:
try:
logger.info(
"Deleting series folder: key=%s path=%s",
key, abs_folder,
)
shutil.rmtree(abs_folder)
logger.info(
"Deleted series folder: key=%s path=%s",
key, abs_folder,
)
result.deleted_folder = True
except Exception as exc:
logger.error(
"Failed to delete series folder: key=%s path=%s error=%s",
key, abs_folder, str(exc),
)
result.folder_error = str(exc)
result.success = False
# --- Build message ---
parts = []
if result.deleted_from_database and not result.database_error:
parts.append("removed from database")
if result.deleted_folder and not result.folder_error:
parts.append("folder deleted from filesystem")
if result.database_error:
parts.append(f"database error: {result.database_error}")
if result.folder_error:
parts.append(f"folder error: {result.folder_error}")
if parts:
result.message = "; ".join(parts)
else:
result.message = "No action taken."
logger.info(
"Delete series completed: key=%s deleted_db=%s deleted_folder=%s",
key, result.deleted_from_database, result.deleted_folder,
)
return result
async def _broadcast_series_deleted(self, key: str, name: str) -> None:
"""Broadcast series_deleted event via WebSocket."""
try:
await self._websocket_service.broadcast_series_deleted(
key=key,
name=name,
)
logger.info(
"series_deleted broadcast sent: key=%s name=%s",
key, name,
)
except Exception as exc:
logger.warning(
"Failed to broadcast series_deleted: key=%s error=%s",
key, str(exc),
)
def get_anime_service(series_app: SeriesApp) -> AnimeService:
"""Factory used for creating AnimeService with a SeriesApp instance."""

View File

@@ -651,9 +651,9 @@ class WebSocketService:
async def shutdown(self, timeout: float = 5.0) -> None:
"""Gracefully shutdown the WebSocket service.
Broadcasts shutdown notification and closes all connections.
Args:
timeout: Maximum time (seconds) to wait for shutdown
"""
@@ -661,6 +661,34 @@ class WebSocketService:
await self._manager.shutdown(timeout=timeout)
logger.info("WebSocket service shutdown complete")
async def broadcast_series_deleted(
self,
key: str,
name: str,
) -> None:
"""Broadcast a series_deleted event to all connected clients.
Notifies clients that a series has been deleted so they can
remove it from their UI in real-time.
Args:
key: Series key that was deleted (primary identifier)
name: Series name for display purposes
"""
message = {
"type": "series_deleted",
"timestamp": datetime.now(timezone.utc).isoformat(),
"data": {
"key": key,
"name": name,
},
}
await self._manager.broadcast(message)
logger.info(
"Broadcast series_deleted key=%s name=%s",
key, name,
)
# Singleton instance for application-wide access
_websocket_service: Optional[WebSocketService] = None

View File

@@ -311,6 +311,24 @@
color: var(--color-text-secondary);
}
.context-menu-item.danger {
color: var(--color-error, #e74c3c);
}
.context-menu-item.danger i {
color: var(--color-error, #e74c3c);
}
.context-menu-item.danger:hover {
background-color: rgba(231, 76, 60, 0.1);
}
.context-menu-divider {
height: 1px;
background: var(--color-border);
margin: var(--spacing-xs) 0;
}
/* ============================================================================
NFO Diagnostics
============================================================================ */
@@ -415,4 +433,125 @@
flex-shrink: 0;
}
/* ============================================================================
Delete Anime Modal
============================================================================ */
#delete-modal .modal-content {
max-width: 480px;
}
.delete-modal-section {
margin-bottom: var(--spacing-md);
}
.delete-modal-series-info {
background: var(--color-background-subtle);
border-radius: var(--border-radius);
padding: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.delete-modal-series-info p {
margin: 0 0 var(--spacing-xs) 0;
font-size: var(--font-size-body);
}
.delete-modal-series-info p:last-child {
margin-bottom: 0;
}
.delete-modal-series-name {
font-weight: 600;
color: var(--color-text-primary);
}
.delete-modal-series-key {
font-family: 'Consolas', 'Monaco', monospace;
font-size: var(--font-size-caption);
color: var(--color-text-tertiary);
}
.delete-modal-options {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
.delete-modal-options label {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
cursor: pointer;
font-size: var(--font-size-body);
}
.delete-modal-options input[type="checkbox"] {
margin-top: 3px;
accent-color: var(--color-accent);
}
.delete-modal-warning {
color: var(--color-error, #e74c3c);
font-size: var(--font-size-caption);
margin-top: var(--spacing-xs);
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.delete-modal-confirm-label {
font-size: var(--font-size-body);
color: var(--color-text-secondary);
margin-bottom: var(--spacing-xs);
}
.delete-modal-confirm-label strong {
color: var(--color-text-primary);
}
#delete-confirm-input {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
font-size: var(--font-size-body);
background: var(--color-background);
color: var(--color-text-primary);
transition: border-color 0.15s ease;
}
#delete-confirm-input:focus {
outline: none;
border-color: var(--color-accent);
}
#delete-confirm-input.matched {
border-color: var(--color-success);
background: rgba(46, 204, 113, 0.05);
}
#delete-error {
margin-top: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: rgba(231, 76, 60, 0.1);
border: 1px solid var(--color-error, #e74c3c);
border-radius: var(--border-radius);
color: var(--color-error, #e74c3c);
font-size: var(--font-size-caption);
}
.delete-modal-actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
margin-top: var(--spacing-lg);
}
#delete-confirm-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -46,6 +46,7 @@ AniWorld.IndexApp = (function() {
AniWorld.ScanManager.init();
AniWorld.ConfigManager.init();
AniWorld.ContextMenu.init();
AniWorld.DeleteModal.init();
// Bind global events
bindGlobalEvents();

View File

@@ -71,6 +71,11 @@ AniWorld.ContextMenu = (function() {
<i class="fa-solid fa-gear"></i>
<span>Anime Settings</span>
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item danger" data-action="delete-anime">
<i class="fa-solid fa-trash"></i>
<span>Delete Anime</span>
</div>
`;
document.body.appendChild(menuElement);
@@ -104,6 +109,17 @@ AniWorld.ContextMenu = (function() {
// Navigate to anime settings page with this series selected
window.location.href = '/anime/settings?key=' + encodeURIComponent(key);
});
// Delete Anime - opens the confirmation modal
menuElement.querySelector('[data-action="delete-anime"]').addEventListener('click', function() {
const key = currentSeriesKey;
hide();
if (AniWorld.DeleteModal && AniWorld.DeleteModal.show) {
AniWorld.DeleteModal.show(key);
} else {
console.error('[ContextMenu] DeleteModal not found on AniWorld');
}
});
}
/**

View File

@@ -0,0 +1,317 @@
/**
* AniWorld - Delete Anime Modal Module
*
* Handles the delete confirmation modal for anime series.
* Users must type "delete" to enable the confirm button.
*
* Dependencies: constants.js, api-client.js, ui-utils.js
*/
var AniWorld = window.AniWorld || {};
AniWorld.DeleteModal = (function() {
'use strict';
const API = '/api/anime';
let currentKey = null;
let currentSeriesName = null;
let modalElement = null;
let confirmBtn = null;
let confirmInput = null;
let deleteDbCheckbox = null;
let deleteFolderCheckbox = null;
let errorElement = null;
let isSubmitting = false;
/**
* Initialize the modal — inject HTML into body if not already present.
*/
function init() {
injectModalHTML();
cacheElements();
bindEvents();
console.info('[DeleteModal] initialized');
}
/**
* Build the modal HTML once and append to document.body.
*/
function injectModalHTML() {
if (document.getElementById('delete-modal')) return;
var div = document.createElement('div');
div.id = 'delete-modal';
div.className = 'modal hidden';
div.innerHTML =
'<div class="modal-overlay"></div>' +
'<div class="modal-content">' +
'<div class="modal-header">' +
'<h3>Delete Anime</h3>' +
'</div>' +
'<div class="modal-body">' +
'<div class="delete-modal-series-info">' +
'<p class="delete-modal-series-name" id="delete-modal-series-name"></p>' +
'<p class="delete-modal-series-key" id="delete-modal-series-key"></p>' +
'</div>' +
'<div class="delete-modal-options">' +
'<label>' +
'<input type="checkbox" id="delete-db-checkbox" checked> ' +
'Remove from database (recommended)' +
'</label>' +
'<label>' +
'<input type="checkbox" id="delete-folder-checkbox"> ' +
'Delete folder from filesystem' +
'</label>' +
'<p class="delete-modal-warning" id="delete-folder-warning" style="display:none;">' +
'<i class="fas fa-exclamation-triangle"></i> ' +
'This will permanently delete the folder and ALL files inside it!' +
'</p>' +
'</div>' +
'<label class="delete-modal-confirm-label" for="delete-confirm-input">' +
'Type <strong>delete</strong> to confirm:' +
'</label>' +
'<input type="text" id="delete-confirm-input" ' +
'placeholder="delete" autocomplete="off" spellcheck="false">' +
'<div id="delete-error" class="hidden"></div>' +
'</div>' +
'<div class="modal-footer delete-modal-actions">' +
'<button class="btn" id="delete-cancel-btn">Cancel</button>' +
'<button class="btn btn-danger" id="delete-confirm-btn" disabled>Delete</button>' +
'</div>' +
'</div>';
document.body.appendChild(div);
}
/**
* Cache DOM element references.
*/
function cacheElements() {
modalElement = document.getElementById('delete-modal');
confirmBtn = document.getElementById('delete-confirm-btn');
confirmInput = document.getElementById('delete-confirm-input');
deleteDbCheckbox = document.getElementById('delete-db-checkbox');
deleteFolderCheckbox = document.getElementById('delete-folder-checkbox');
errorElement = document.getElementById('delete-error');
}
/**
* Bind event listeners on the modal.
*/
function bindEvents() {
// Cancel button
document.getElementById('delete-cancel-btn').addEventListener('click', hide);
// Close on backdrop click
modalElement.querySelector('.modal-overlay').addEventListener('click', hide);
// Escape key to close
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !isSubmitting && !modalElement.classList.contains('hidden')) {
hide();
}
});
// Folder checkbox toggle — show/hide warning
deleteFolderCheckbox.addEventListener('change', function() {
var warning = document.getElementById('delete-folder-warning');
if (warning) {
warning.style.display = deleteFolderCheckbox.checked ? 'flex' : 'none';
}
});
// Confirm input — validate and update button state
confirmInput.addEventListener('input', function() {
var value = confirmInput.value;
var isMatch = value === 'delete';
confirmBtn.disabled = !isMatch || isSubmitting;
confirmInput.classList.toggle('matched', isMatch);
});
// Confirm button
confirmBtn.addEventListener('click', handleConfirm);
// Click outside modal content to close
modalElement.addEventListener('click', function(e) {
if (e.target === modalElement) {
hide();
}
});
}
/**
* Show the delete modal for a given series.
* @param {string} key - Series key
*/
function show(key) {
console.info('[DeleteModal] Opening for key:', key);
// Get series info from SeriesManager if available
var seriesData = null;
if (AniWorld.SeriesManager && AniWorld.SeriesManager.findByKey) {
seriesData = AniWorld.SeriesManager.findByKey(key);
}
currentKey = key;
currentSeriesName = seriesData ? (seriesData.name || key) : key;
// Populate modal
document.getElementById('delete-modal-series-name').textContent = currentSeriesName;
document.getElementById('delete-modal-series-key').textContent = 'Key: ' + key;
// Reset state
confirmInput.value = '';
confirmInput.classList.remove('matched');
confirmBtn.disabled = true;
isSubmitting = false;
errorElement.classList.add('hidden');
errorElement.textContent = '';
deleteDbCheckbox.checked = true;
deleteFolderCheckbox.checked = false;
document.getElementById('delete-folder-warning').style.display = 'none';
// Show modal
modalElement.classList.remove('hidden');
confirmInput.focus();
}
/**
* Hide the modal and reset state.
*/
function hide() {
if (isSubmitting) return; // Don't close while submitting
modalElement.classList.add('hidden');
currentKey = null;
currentSeriesName = null;
}
/**
* Handle confirm button click.
*/
async function handleConfirm() {
if (confirmBtn.disabled || isSubmitting) return;
var deleteDb = deleteDbCheckbox.checked;
var deleteFolder = deleteFolderCheckbox.checked;
var confirmText = confirmInput.value.trim();
// Validate confirm text
if (confirmText !== 'delete') {
showError('You must type exactly "delete" to confirm.');
return;
}
// Validate at least one option selected
if (!deleteDb && !deleteFolder) {
showError('Please select at least one delete option.');
return;
}
isSubmitting = true;
confirmBtn.disabled = true;
confirmBtn.textContent = 'Deleting...';
errorElement.classList.add('hidden');
console.info('[DeleteModal] Initiating delete:', {
key: currentKey,
delete_database: deleteDb,
delete_folder: deleteFolder
});
try {
var response = await AniWorld.ApiClient.request(
API + '/' + encodeURIComponent(currentKey),
{
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
delete_database: deleteDb,
delete_folder: deleteFolder,
confirm_text: confirmText
})
}
);
if (!response) {
// Auth failure already redirected
hide();
return;
}
if (response.status === 401) {
AniWorld.Auth.removeToken();
window.location.href = '/login';
hide();
return;
}
if (response.status === 400) {
var body = await response.json().catch(function() { return {}; });
showError(body.detail || 'Invalid request: ' + response.status);
isSubmitting = false;
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
return;
}
if (response.status === 404) {
showError('Series not found: ' + currentKey);
isSubmitting = false;
confirmBtn.textContent = 'Delete';
return;
}
if (!response.ok) {
var text = await response.text();
showError('Delete failed: HTTP ' + response.status + ' ' + text);
isSubmitting = false;
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
return;
}
var result = await response.json();
console.info('[DeleteModal] Delete succeeded:', result);
// Show success message based on what was deleted
var msgParts = [];
if (result.deleted_from_database) msgParts.push('removed from database');
if (result.deleted_folder) msgParts.push('folder deleted from filesystem');
if (result.database_error) msgParts.push('DB error: ' + result.database_error);
if (result.folder_error) msgParts.push('Folder error: ' + result.folder_error);
var successMsg = msgParts.length > 0
? msgParts.join('; ')
: 'Delete completed.';
AniWorld.UI.showToast(successMsg, result.success ? 'success' : 'warning');
hide();
// Remove the card from the grid directly
if (AniWorld.SeriesManager && AniWorld.SeriesManager.removeSeries) {
AniWorld.SeriesManager.removeSeries(currentKey);
}
} catch (err) {
console.error('[DeleteModal] Delete request failed:', err);
showError('Network error: ' + (err && err.message ? err.message : String(err)));
isSubmitting = false;
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
}
}
/**
* Show an error message in the modal.
* @param {string} msg
*/
function showError(msg) {
errorElement.textContent = msg;
errorElement.classList.remove('hidden');
}
// Public API
return {
init: init,
show: show,
hide: hide
};
})();

View File

@@ -549,6 +549,41 @@ AniWorld.SeriesManager = (function() {
renderSeries();
}
/**
* Remove a series from the local data arrays and re-render the grid.
* Called after a successful delete or when receiving series_deleted WS event.
* @param {string} key - Series key to remove
*/
function removeSeries(key) {
if (!key) return;
var removedFromData = false;
var removedFromFiltered = false;
if (seriesData) {
var dataIdx = seriesData.findIndex(function(s) { return s.key === key; });
if (dataIdx >= 0) {
seriesData.splice(dataIdx, 1);
removedFromData = true;
}
}
if (filteredSeriesData) {
var filteredIdx = filteredSeriesData.findIndex(function(s) { return s.key === key; });
if (filteredIdx >= 0) {
filteredSeriesData.splice(filteredIdx, 1);
removedFromFiltered = true;
}
}
if (removedFromData || removedFromFiltered) {
console.info('[SeriesManager] Removed series from local state:', key);
renderSeries();
} else {
console.warn('[SeriesManager] Series not found in local state:', key);
}
}
// Public API
return {
init: init,
@@ -560,6 +595,7 @@ AniWorld.SeriesManager = (function() {
findByKey: findByKey,
updateSeriesLoadingStatus: updateSeriesLoadingStatus,
updateSingleSeries: updateSingleSeries,
updateSeriesKey: updateSeriesKey
updateSeriesKey: updateSeriesKey,
removeSeries: removeSeries
};
})();

View File

@@ -160,6 +160,15 @@ AniWorld.IndexSocketHandler = (function() {
}
});
// Series deleted event — remove the card from the UI
socket.on(WS_EVENTS.SERIES_DELETED, function(data) {
console.info('[SocketHandler] Series deleted:', data);
if (data && data.key && AniWorld.SeriesManager && AniWorld.SeriesManager.removeSeries) {
AniWorld.SeriesManager.removeSeries(data.key);
AniWorld.UI.showToast('Series deleted: ' + (data.name || data.key), 'success');
}
});
// Download events
socket.on(WS_EVENTS.DOWNLOAD_STARTED, function(data) {
isDownloading = true;

View File

@@ -104,6 +104,7 @@ AniWorld.Constants = (function() {
// Series events
SERIES_UPDATED: 'series_updated',
SERIES_LOADING_UPDATE: 'series_loading_update',
SERIES_DELETED: 'series_deleted',
// Scheduled scan events
SCHEDULED_RESCAN_STARTED: 'scheduled_rescan_started',

View File

@@ -693,6 +693,7 @@
<script src="/static/js/index/nfo-config.js?v={{ static_v }}"></script>
<script src="/static/js/index/config-manager.js?v={{ static_v }}"></script>
<script src="/static/js/index/socket-handler.js?v={{ static_v }}"></script>
<script src="/static/js/index/delete-modal.js?v={{ static_v }}"></script>
<script src="/static/js/index/app-init.js?v={{ static_v }}"></script>
</body>

View File

@@ -0,0 +1,400 @@
"""Tests for DELETE /api/anime/{key} endpoint."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from src.server.api import anime as anime_module
from src.server.fastapi_app import app
from src.server.services.auth_service import auth_service
class FakeSerie:
"""Mock Serie object for testing."""
def __init__(self, key, name, folder, episodeDict=None):
self.key = key
self.name = name
self.folder = folder
self.episodeDict = episodeDict or {}
self.site = "aniworld.to"
class FakeSeriesApp:
"""Mock SeriesApp for testing."""
def __init__(self):
self.list = self
self.serie_scanner = MagicMock()
self.directory = "/tmp/fake_anime"
self.keyDict = {}
self._items = [
FakeSerie("test-show-key", "Test Show", "Test Show (2023)", {1: [1, 2]}),
]
for item in self._items:
self.keyDict[item.key] = item
def GetList(self):
return self._items
@pytest.fixture
async def authenticated_client():
"""Return an async HTTP client authenticated with a valid token."""
if not auth_service.is_configured():
auth_service.setup_master_password("TestPass123!")
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
r = await ac.post("/api/auth/login", json={"password": "TestPass123!"})
assert r.status_code == 200, f"Login failed: {r.status_code} {r.text}"
token = r.json()["access_token"]
ac.headers["Authorization"] = f"Bearer {token}"
yield ac
@pytest.fixture
def mock_series_app():
"""Create a mock SeriesApp for dependency injection."""
return FakeSeriesApp()
class TestDeleteAnimeEndpoint:
"""Tests for DELETE /api/anime/{key}."""
@pytest.mark.asyncio
async def test_delete_requires_authentication(self):
"""DELETE without token returns 401."""
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
r = await ac.request(
"DELETE",
"/api/anime/test-key",
json={"delete_database": True, "delete_folder": False, "confirm_text": "delete"},
)
assert r.status_code == 401
@pytest.mark.asyncio
async def test_delete_invalid_confirm_text(self, authenticated_client):
"""DELETE with wrong confirm_text returns 400."""
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-key",
json={
"delete_database": True,
"delete_folder": False,
"confirm_text": "DELETE", # Wrong case
},
)
assert r.status_code == 400
assert "delete" in r.json()["detail"].lower()
@pytest.mark.asyncio
async def test_delete_invalid_confirm_text_empty(self, authenticated_client):
"""DELETE with empty confirm_text returns 400."""
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-key",
json={
"delete_database": True,
"delete_folder": False,
"confirm_text": "",
},
)
assert r.status_code == 400
@pytest.mark.asyncio
async def test_delete_no_option_selected(self, authenticated_client):
"""DELETE with both flags False returns 400."""
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-key",
json={
"delete_database": False,
"delete_folder": False,
"confirm_text": "delete",
},
)
assert r.status_code == 400
assert "at least one" in r.json()["detail"].lower()
@pytest.mark.asyncio
async def test_delete_database_only_success(self, authenticated_client):
"""DELETE with delete_database=True succeeds and returns result."""
from src.server.models.anime import DeleteSeriesResult
mock_result = DeleteSeriesResult(
success=True,
key="test-show-key",
name="Test Show",
deleted_from_database=True,
deleted_folder=False,
folder_path=None,
database_error=None,
folder_error=None,
message="Removed from database.",
)
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_svc.delete_series = AsyncMock(return_value=mock_result)
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-show-key",
json={
"delete_database": True,
"delete_folder": False,
"confirm_text": "delete",
},
)
assert r.status_code == 200
data = r.json()
assert data["success"] is True
assert data["key"] == "test-show-key"
assert data["deleted_from_database"] is True
assert data["deleted_folder"] is False
@pytest.mark.asyncio
async def test_delete_folder_only_success(self, authenticated_client):
"""DELETE with delete_folder=True succeeds."""
from src.server.models.anime import DeleteSeriesResult
mock_result = DeleteSeriesResult(
success=True,
key="test-show-key",
name="Test Show",
deleted_from_database=False,
deleted_folder=True,
folder_path="/anime/Test Show",
database_error=None,
folder_error=None,
message="Folder deleted.",
)
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_svc.delete_series = AsyncMock(return_value=mock_result)
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-show-key",
json={
"delete_database": False,
"delete_folder": True,
"confirm_text": "delete",
},
)
assert r.status_code == 200
data = r.json()
assert data["deleted_folder"] is True
assert data["deleted_from_database"] is False
@pytest.mark.asyncio
async def test_delete_both_success(self, authenticated_client):
"""DELETE with both flags True succeeds."""
from src.server.models.anime import DeleteSeriesResult
mock_result = DeleteSeriesResult(
success=True,
key="test-show-key",
name="Test Show",
deleted_from_database=True,
deleted_folder=True,
folder_path="/anime/Test Show",
database_error=None,
folder_error=None,
message="Removed from database and folder deleted.",
)
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_svc.delete_series = AsyncMock(return_value=mock_result)
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-show-key",
json={
"delete_database": True,
"delete_folder": True,
"confirm_text": "delete",
},
)
assert r.status_code == 200
data = r.json()
assert data["deleted_from_database"] is True
assert data["deleted_folder"] is True
@pytest.mark.asyncio
async def test_delete_series_not_found(self, authenticated_client):
"""DELETE with unknown key returns 404."""
from src.server.models.anime import DeleteSeriesResult
mock_result = DeleteSeriesResult(
success=False,
key="nonexistent-key",
name="",
deleted_from_database=False,
deleted_folder=False,
folder_path=None,
database_error=None,
folder_error=None,
message="Series not found.",
)
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_svc.delete_series = AsyncMock(return_value=mock_result)
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/nonexistent-key",
json={
"delete_database": True,
"delete_folder": False,
"confirm_text": "delete",
},
)
assert r.status_code == 404
assert "not found" in r.json()["detail"].lower()
@pytest.mark.asyncio
async def test_delete_service_error_returns_500(self, authenticated_client):
"""DELETE when service raises unexpected error returns 500."""
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_svc.delete_series = AsyncMock(side_effect=RuntimeError("Unexpected error"))
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-show-key",
json={
"delete_database": True,
"delete_folder": False,
"confirm_text": "delete",
},
)
assert r.status_code == 500
assert "delete failed" in r.json()["detail"].lower()
@pytest.mark.asyncio
async def test_delete_body_confirm_text_required(self, authenticated_client):
"""DELETE body must contain confirm_text field."""
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_get_svc.return_value = mock_svc
# Missing confirm_text entirely
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-show-key",
json={
"delete_database": True,
"delete_folder": False,
},
)
# Pydantic validation error
assert r.status_code in (400, 422)
@pytest.mark.asyncio
async def test_delete_confirm_text_too_long(self, authenticated_client):
"""DELETE with extremely long confirm_text is rejected."""
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-show-key",
json={
"delete_database": True,
"delete_folder": False,
"confirm_text": "delete" + "x" * 10000,
},
)
# Should be rejected as invalid confirm_text
assert r.status_code in (400, 422)
@pytest.mark.asyncio
async def test_delete_confirm_text_with_whitespace_rejected(self, authenticated_client):
"""DELETE with whitespace-padded confirm_text is rejected."""
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-show-key",
json={
"delete_database": True,
"delete_folder": False,
"confirm_text": " delete ", # Has spaces
},
)
assert r.status_code == 400
assert "delete" in r.json()["detail"].lower()
@pytest.mark.asyncio
async def test_delete_confirm_text_with_newline_rejected(self, authenticated_client):
"""DELETE with newline in confirm_text is rejected."""
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-show-key",
json={
"delete_database": True,
"delete_folder": False,
"confirm_text": "delet\ne", # Has newline
},
)
assert r.status_code == 400
@pytest.mark.asyncio
async def test_delete_path_traversal_in_key_rejected(self, authenticated_client):
"""DELETE with path traversal key returns 400 or 404."""
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_svc.delete_series = AsyncMock(side_effect=ValueError("Path traversal detected"))
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/../../../etc/passwd",
json={
"delete_database": True,
"delete_folder": False,
"confirm_text": "delete",
},
)
# Should either be rejected by the service or return an error
assert r.status_code in (400, 404, 422)

View File

@@ -0,0 +1,326 @@
"""
Frontend unit tests for delete-modal.js.
Tests the DeleteModal JavaScript module in isolation using a mock DOM.
"""
# pyright: reportUndefinedVariable=false
from unittest.mock import AsyncMock, MagicMock
import pytest
@pytest.fixture
def mock_window(monkeypatch):
"""Mock window.AniWorld namespace."""
class MockUI:
showToast_called_with = []
@staticmethod
def showToast(msg, level):
MockUI.showToast_called_with.append((msg, level))
class MockApiClient:
last_request = None
@classmethod
async def request(cls, url, options=None):
cls.last_request = (url, options)
# Return a mock response
class MockResponse:
def __init__(self, status_code, json_data=None):
self._status = status_code
self._json = json_data
@property
def ok(self):
return 200 <= self._status < 300
@property
def status(self):
return self._status
async def json(self):
return self._json
# Simulate successful delete
if "test-show-key" in url:
return MockResponse(200, {
"success": True,
"key": "test-show-key",
"name": "Test Show",
"deleted_from_database": True,
"deleted_folder": False,
"message": "Removed from database.",
})
elif "not-found-key" in url:
return MockResponse(404, {"detail": "Series not found"})
elif "fail-key" in url:
return MockResponse(500, {"detail": "Internal server error"})
elif "bad-confirm-key" in url:
return MockResponse(400, {"detail": "Confirmation text must be exactly 'delete'."})
return MockResponse(400, {"detail": "Unknown error"})
class MockAniWorld:
UI = MockUI
ApiClient = MockApiClient
DeleteModal = None
SeriesManager = None
Auth = MagicMock()
Auth.removeToken = MagicMock()
monkeypatch.setattr("window.AniWorld", MockAniWorld)
return MockAniWorld
class TestDeleteModalHTML:
"""Tests for the delete modal HTML structure and validation."""
def test_delete_modal_injects_html(self, mock_window):
"""injectModalHTML creates the modal element in DOM."""
# Simulate what injectModalHTML does
div = document.createElement('div')
div.id = 'delete-modal'
div.className = 'modal hidden'
div.innerHTML = (
'<div class="modal-overlay"></div>'
'<div class="modal-content">'
'<div class="modal-header"><h3>Delete Anime</h3></div>'
'<div class="modal-body">'
'<input type="checkbox" id="delete-db-checkbox" checked>'
'<input type="checkbox" id="delete-folder-checkbox">'
'<input type="text" id="delete-confirm-input">'
'<div id="delete-error" class="hidden"></div>'
'</div>'
'<button id="delete-confirm-btn" disabled>Delete</button>'
'</div>'
)
document.body.appendChild(div)
modal = document.getElementById('delete-modal')
assert modal is not None
assert modal.querySelector('#delete-db-checkbox') is not None
assert modal.querySelector('#delete-folder-checkbox') is not None
assert modal.querySelector('#delete-confirm-input') is not None
assert modal.querySelector('#delete-confirm-btn') is not None
def test_confirm_input_disables_button_until_delete_typed(self, mock_window):
"""Button is disabled until user types 'delete'."""
# Simulate the input event handler logic
confirm_input = {"value": "", "classList": {"toggle": MagicMock()}}
confirm_btn = {"disabled": False}
# Initially empty - button should be disabled
is_match = confirm_input["value"] == "delete"
confirm_btn["disabled"] = not is_match
assert confirm_btn["disabled"] is True
# User types 'del'
confirm_input["value"] = "del"
is_match = confirm_input["value"] == "delete"
confirm_btn["disabled"] = not is_match
assert confirm_btn["disabled"] is True
# User types 'delete'
confirm_input["value"] = "delete"
is_match = confirm_input["value"] == "delete"
confirm_btn["disabled"] = not is_match
assert confirm_btn["disabled"] is False
def test_confirm_input_matched_class_toggles(self, mock_window):
"""Input gets 'matched' CSS class when value is 'delete'."""
matched_states = []
for value in ["", "del", "delete", "Delete", "delete "]:
is_match = value == "delete"
matched_states.append(is_match)
assert matched_states == [False, False, True, False, False]
def test_folder_checkbox_shows_warning_when_checked(self, mock_window):
"""Folder warning appears when delete-folder checkbox is checked."""
warning_shown = []
for is_checked in [False, True, False]:
warning_shown.append(is_checked)
assert warning_shown[0] is False
assert warning_shown[1] is True
assert warning_shown[2] is False
def test_at_least_one_option_required_validation(self, mock_window):
"""Modal should reject when neither checkbox is selected."""
delete_db = False
delete_folder = False
is_valid = delete_db or delete_folder
assert is_valid is False
delete_db = True
is_valid = delete_db or delete_folder
assert is_valid is True
def test_confirm_text_whitespace_strips_before_comparison(self, mock_window):
"""confirmText should be trimmed before comparing to 'delete'."""
test_cases = [
("delete", True),
(" delete", False),
("delete ", False),
(" delete ", False),
("deletex", False),
("", False),
]
for text, expected in test_cases:
result = text == "delete"
assert result is expected, f"'{text}' should be {expected}"
class TestDeleteModalAPI:
"""Tests for the delete modal API interaction logic."""
@pytest.mark.asyncio
async def test_api_called_with_correct_url_and_method(self, mock_window):
"""DELETE request is sent to correct endpoint."""
from AniWorld import DeleteModal
# Simulate the API call
url = "/api/anime/test-show-key"
options = {
"method": "DELETE",
"headers": {"Content-Type": "application/json"},
"body": JSON.stringify({
"delete_database": True,
"delete_folder": False,
"confirm_text": "delete"
})
}
response = await mock_window.ApiClient.request(url, options)
assert response.status == 200
@pytest.mark.asyncio
async def test_api_returns_404_shows_not_found_error(self, mock_window):
"""API 404 response shows 'Series not found' error in modal."""
response = await mock_window.ApiClient.request(
"/api/anime/not-found-key",
{"method": "DELETE", "body": "{}"}
)
assert response.status == 404
data = await response.json()
assert "not found" in data["detail"].lower()
@pytest.mark.asyncio
async def test_api_returns_401_redirects_to_login(self, mock_window):
"""API 401 response redirects to login page."""
# Simulate auth failure
mock_window.ApiClient.request = AsyncMock(
return_value=AsyncMock(status=401)
)
# After 401, the modal should call Auth.removeToken and redirect
response = await mock_window.ApiClient.request("/api/anime/test", {})
# 401 handling triggers logout
mock_window.Auth.removeToken.assert_called()
@pytest.mark.asyncio
async def test_api_returns_400_shows_validation_error(self, mock_window):
"""API 400 response shows error message in modal."""
response = await mock_window.ApiClient.request(
"/api/anime/bad-confirm-key",
{"method": "DELETE"}
)
assert response.status == 400
data = await response.json()
assert "delete" in data["detail"].lower()
@pytest.mark.asyncio
async def test_api_network_error_shows_network_message(self, mock_window):
"""Network failure shows 'Network error' message."""
mock_window.ApiClient.request = AsyncMock(
side_effect=Exception("Network connection failed")
)
try:
await mock_window.ApiClient.request("/api/anime/test", {})
except Exception as e:
error_msg = str(e)
assert "network" in error_msg.lower() or "failed" in error_msg.lower()
class TestDeleteModalSeriesManagerIntegration:
"""Tests for SeriesManager.removeSeries integration."""
def test_remove_series_called_after_success(self, mock_window):
"""After successful delete, removeSeries(key) is called."""
# This tests the integration logic:
# After API returns 200, call AniWorld.SeriesManager.removeSeries(key)
key = "test-show-key"
# Mock SeriesManager
remove_called_with = []
class MockSeriesManager:
@staticmethod
def removeSeries(k):
remove_called_with.append(k)
mock_window.SeriesManager = MockSeriesManager
# Simulate: after successful API response
result = {"success": True, "key": key, "name": "Test Show"}
if result["success"] and mock_window.SeriesManager:
mock_window.SeriesManager.removeSeries(result["key"])
assert remove_called_with == [key]
def test_remove_series_not_called_on_failure(self, mock_window):
"""removeSeries is NOT called when API returns error."""
remove_called_with = []
class MockSeriesManager:
@staticmethod
def removeSeries(k):
remove_called_with.append(k)
mock_window.SeriesManager = MockSeriesManager
# Simulate: API returns error
result = {"success": False, "key": "test-show-key", "message": "Not found"}
if result["success"] and mock_window.SeriesManager:
mock_window.SeriesManager.removeSeries(result["key"])
assert remove_called_with == []
class TestDeleteModalConstants:
"""Tests for SERIES_DELETED WebSocket event constant."""
def test_series_deleted_constant_referenced_in_constants_js(self):
"""WS_EVENTS.SERIES_DELETED constant exists in constants.js."""
import os
constants_path = os.path.join(
os.path.dirname(__file__),
'..', '..', '..',
'src', 'server', 'web', 'static', 'js', 'shared', 'constants.js'
)
with open(constants_path, 'r') as f:
content = f.read()
assert 'SERIES_DELETED' in content
assert "SERIES_DELETED: 'series_deleted'" in content
def test_series_deleted_constant_referenced_in_socket_handler(self):
"""WS_EVENTS.SERIES_DELETED is handled in socket-handler.js."""
import os
handler_path = os.path.join(
os.path.dirname(__file__),
'..', '..', '..',
'src', 'server', 'web', 'static', 'js', 'index', 'socket-handler.js'
)
with open(handler_path, 'r') as f:
content = f.read()
assert 'SERIES_DELETED' in content

View File

@@ -0,0 +1,187 @@
"""
Security tests for delete anime feature.
Tests path traversal protection, confirm_text validation, and
other security controls around the delete anime feature.
"""
import os
import pytest
class TestDeleteAnimeSecurity:
"""Security tests for the delete anime feature."""
@pytest.fixture
def anime_service_code(self):
"""Read the anime_service.py source code for security checks."""
path = os.path.join(
os.path.dirname(__file__), '..', '..',
'src', 'server', 'services', 'anime_service.py'
)
with open(path, 'r') as f:
return f.read()
@pytest.fixture
def delete_modal_code(self):
"""Read the delete-modal.js source code for security checks."""
path = os.path.join(
os.path.dirname(__file__), '..', '..',
'src', 'server', 'web', 'static', 'js', 'index', 'delete-modal.js'
)
with open(path, 'r') as f:
return f.read()
def test_delete_series_uses_is_safe_path(self, anime_service_code):
"""delete_series uses is_safe_path before deleting folders."""
assert 'is_safe_path' in anime_service_code
assert 'folder_path' in anime_service_code
def test_delete_series_checks_anime_base_directory(self, anime_service_code):
"""delete_series validates paths against the anime base directory."""
# Should reference the anime directory for path comparison
assert 'anime_base_dir' in anime_service_code or 'directory_to_search' in anime_service_code
def test_delete_series_no_hardcoded_paths(self, anime_service_code):
"""delete_series has no hardcoded dangerous paths."""
dangerous = ['/etc/passwd', '/root/.ssh', 'C:\\Windows\\System32']
for path in dangerous:
assert path not in anime_service_code
def test_delete_modal_encodes_key_in_url(self, delete_modal_code):
"""delete-modal.js encodes the series key in the API URL."""
# Should use encodeURIComponent or similar for the key
assert 'encodeURIComponent' in delete_modal_code
def test_delete_modal_no_inner_html_with_user_data(self, delete_modal_code):
"""delete-modal.js does not use innerHTML with unsanitized user data."""
# innerHTML should not be used with direct variable interpolation
# that could allow XSS
lines = delete_modal_code.split('\n')
dangerous_lines = [
line for line in lines
if 'innerHTML' in line and 'currentSeriesName' in line
and 'escapeHtml' not in line
]
assert len(dangerous_lines) == 0, \
"innerHTML used with currentSeriesName without escapeHtml"
def test_delete_modal_uses_textContent_for_user_visible_text(self, delete_modal_code):
"""User-visible text in modal uses safe DOM methods."""
# Should use textContent or similar instead of innerHTML for data
# This is implicit in using template literals with ${} - but check no obvious XSS
assert '<script>' not in delete_modal_code.lower()
assert 'onclick=' not in delete_modal_code.lower()
def test_delete_modal_confirm_text_not_used_in_url(self, delete_modal_code):
"""confirm_text is only used in JSON body, never in URLs."""
lines = delete_modal_code.split('\n')
for line in lines:
if 'confirm_text' in line:
# confirm_text should only appear in JSON body serialization
assert 'URL' not in line and 'url' not in line or 'body' in line
def test_delete_modal_has_error_display_element(self, delete_modal_code):
"""Modal has a dedicated error display element (not using alert())."""
assert 'delete-error' in delete_modal_code
assert 'showToast' in delete_modal_code # Uses toast, not alert()
def test_delete_endpoint_validates_confirm_text_exactly(self):
"""The API validates confirm_text is exactly 'delete'."""
# This is enforced in the endpoint code - check the endpoint exists
from src.server.api.anime import router
routes = [r for r in router.routes]
assert len(routes) > 0 # Router has routes
def test_delete_modal_handles_401_without_data_leak(self, delete_modal_code):
"""401 response triggers logout redirect, no data exposure."""
assert 'removeToken' in delete_modal_code
assert "window.location.href = '/login'" in delete_modal_code
def test_delete_modal_no_credentials_in_url(self, delete_modal_code):
"""No credentials or tokens appear in the API URL."""
lines = delete_modal_code.split('\n')
for line in lines:
if 'api/' in line.lower():
# URL should not contain token/password
assert 'token=' not in line.lower()
assert 'password=' not in line.lower()
def test_delete_result_message_is_user_facing_only(self, delete_modal_code):
"""Success/error messages shown to user do not expose internal paths."""
# The toast should show only the message from the API, not raw folder paths
assert 'showToast' in delete_modal_code
def test_delete_confirm_text_min_length_enforced(self):
"""confirm_text field requires minimum length of 6 ('delete')."""
from src.server.models.anime import DeleteSeriesRequest
# The field uses a literal comparison, so exact match is enforced
# Try constructing with wrong confirm_text
import pytest as pt
with pt.raises(Exception):
DeleteSeriesRequest(
delete_database=True,
delete_folder=False,
confirm_text="del" # Too short
)
def test_delete_confirm_text_max_length_reasonable(self):
"""confirm_text has a reasonable max length to prevent DoS."""
from src.server.models.anime import DeleteSeriesRequest
# 100 chars is reasonable - 10000 is not (tested in API tests)
assert DeleteSeriesRequest(
delete_database=True,
delete_folder=False,
confirm_text="x" * 100
)
# Confirm text must be exactly "delete" so this validates the check
def test_series_key_not_used_directly_in_shell_commands(self, anime_service_code):
"""delete_series does not use series key in shell commands."""
dangerous_patterns = ['os.system', 'subprocess.call', 'subprocess.run',
'subprocess.Popen', 'eval(', 'exec(']
for pattern in dangerous_patterns:
assert pattern not in anime_service_code, \
f"Potentially dangerous pattern '{pattern}' found"
def test_delete_modal_loads_before_context_menu_handler(self):
"""delete-modal.js is loaded before app-init.js in the template."""
import os
path = os.path.join(
os.path.dirname(__file__), '..', '..',
'src', 'server', 'web', 'templates', 'index.html'
)
with open(path, 'r') as f:
content = f.read()
delete_pos = content.find('delete-modal.js')
app_init_pos = content.find('app-init.js')
assert delete_pos != -1, "delete-modal.js not found in template"
assert app_init_pos != -1, "app-init.js not found in template"
assert delete_pos < app_init_pos, \
"delete-modal.js must be loaded before app-init.js"
def test_delete_modal_init_in_app_init(self, delete_modal_code):
"""delete-modal.js is initialized in app-init.js."""
import os
path = os.path.join(
os.path.dirname(__file__), '..', '..',
'src', 'server', 'web', 'static', 'js', 'index', 'app-init.js'
)
with open(path, 'r') as f:
content = f.read()
assert 'DeleteModal.init' in content
def test_context_menu_has_delete_action(self):
"""context-menu.js includes the delete-anime action."""
import os
path = os.path.join(
os.path.dirname(__file__), '..', '..',
'src', 'server', 'web', 'static', 'js', 'index', 'context-menu.js'
)
with open(path, 'r') as f:
content = f.read()
assert 'delete-anime' in content
assert 'DeleteModal.show' in content

View File

@@ -0,0 +1,519 @@
"""Unit tests for AnimeService.delete_series()."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.server.services.anime_service import AnimeService, AnimeServiceError
from src.server.services.progress_service import ProgressService
@pytest.fixture
def mock_series_app(tmp_path):
"""Create a mock SeriesApp instance."""
mock_instance = MagicMock()
mock_instance.directory_to_search = str(tmp_path)
mock_instance.series_list = []
mock_instance.search = AsyncMock(return_value=[])
mock_instance.rescan = AsyncMock()
mock_instance.download = AsyncMock(return_value=True)
mock_instance.download_status = None
mock_instance.scan_status = None
return mock_instance
@pytest.fixture
def mock_progress_service():
"""Create a mock ProgressService instance."""
service = MagicMock(spec=ProgressService)
service.start_progress = AsyncMock()
service.update_progress = AsyncMock()
service.complete_progress = AsyncMock()
service.fail_progress = AsyncMock()
return service
@pytest.fixture
def anime_service(tmp_path, mock_series_app, mock_progress_service):
"""Create an AnimeService instance for testing."""
return AnimeService(
series_app=mock_series_app,
progress_service=mock_progress_service,
)
# ---------------------------------------------------------------------------
# Shared DB mock helpers — used inside each test's patch context
# ---------------------------------------------------------------------------
def _make_db_ctx(mock_session: AsyncMock) -> AsyncMock:
"""Build a mock async DB context manager."""
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
mock_ctx.__aexit__ = AsyncMock(return_value=None)
return mock_ctx
# ---------------------------------------------------------------------------
# Tests — patch lives INSIDE each test method, not in a fixture
# ---------------------------------------------------------------------------
class TestDeleteSeriesService:
"""Test AnimeService.delete_series()."""
# ------------------------------------------------------------------
# delete_database=True, delete_folder=False
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_db_only_success(self, anime_service):
"""delete_series with delete_database=True removes series from DB only."""
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
mock_series = MagicMock()
mock_series.key = "test-key"
mock_series.name = "Test Series"
mock_series.folder = "Test Series (2023)"
mock_series.id = 42
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=mock_series,
), patch(
"src.server.database.service.AnimeSeriesService.delete",
new_callable=AsyncMock,
return_value=True,
):
result = await anime_service.delete_series(
key="test-key",
delete_database=True,
delete_folder=False,
)
assert result.success is True
assert result.key == "test-key"
assert result.deleted_from_database is True
assert result.deleted_folder is False
assert result.database_error is None
@pytest.mark.asyncio
async def test_delete_db_only_calls_delete_on_series(self, anime_service):
"""DB delete calls AnimeSeriesService.delete(session, series_id)."""
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
mock_series = MagicMock()
mock_series.key = "test-key"
mock_series.name = "Test Series"
mock_series.folder = "Test Series (2023)"
mock_series.id = 99
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=mock_series,
), patch(
"src.server.database.service.AnimeSeriesService.delete",
new_callable=AsyncMock,
return_value=True,
) as mock_delete:
await anime_service.delete_series(
key="test-key",
delete_database=True,
delete_folder=False,
)
mock_delete.assert_called_once()
call_args = mock_delete.call_args
# First positional arg should be the session
assert call_args[0][0] is mock_session
# Second positional arg should be the series id
assert call_args[0][1] == 99
# ------------------------------------------------------------------
# delete_database=False, delete_folder=True
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_folder_only_success(self, anime_service, tmp_path):
"""delete_series with delete_folder=True deletes folder without touching DB."""
series_folder = tmp_path / "Test Series (2023)"
series_folder.mkdir()
(series_folder / "episode1.mkv").write_text("fake video")
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
mock_series = MagicMock()
mock_series.key = "test-key"
mock_series.name = "Test Series"
mock_series.folder = str(series_folder)
mock_series.id = 1
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=mock_series,
):
result = await anime_service.delete_series(
key="test-key",
delete_database=False,
delete_folder=True,
)
assert result.success is True
assert result.deleted_from_database is False
assert result.deleted_folder is True
assert not series_folder.exists() # Folder actually deleted
@pytest.mark.asyncio
async def test_delete_folder_only_no_db_delete_called(self, anime_service, tmp_path):
"""Folder-only delete never calls AnimeSeriesService.delete()."""
series_folder = tmp_path / "Another Series"
series_folder.mkdir()
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
mock_series = MagicMock()
mock_series.key = "test-key"
mock_series.name = "Another Series"
mock_series.folder = str(series_folder)
mock_series.id = 1
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=mock_series,
), patch(
"src.server.database.service.AnimeSeriesService.delete",
new_callable=AsyncMock,
) as mock_delete:
await anime_service.delete_series(
key="test-key",
delete_database=False,
delete_folder=True,
)
mock_delete.assert_not_called()
# ------------------------------------------------------------------
# delete_database=True, delete_folder=True
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_both_success(self, anime_service, tmp_path):
"""delete_series with both flags removes from DB and deletes folder."""
series_folder = tmp_path / "Test Series (2023)"
series_folder.mkdir()
(series_folder / "episode1.mkv").write_text("fake video")
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
mock_series = MagicMock()
mock_series.key = "test-key"
mock_series.name = "Test Series"
mock_series.folder = str(series_folder)
mock_series.id = 42
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=mock_series,
), patch(
"src.server.database.service.AnimeSeriesService.delete",
new_callable=AsyncMock,
return_value=True,
):
result = await anime_service.delete_series(
key="test-key",
delete_database=True,
delete_folder=True,
)
assert result.success is True
assert result.deleted_from_database is True
assert result.deleted_folder is True
assert result.database_error is None
assert result.folder_error is None
assert not series_folder.exists()
# ------------------------------------------------------------------
# Series not found
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_series_not_found(self, anime_service):
"""delete_series returns failure when series doesn't exist."""
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=None,
):
result = await anime_service.delete_series(
key="nonexistent-key",
delete_database=True,
delete_folder=False,
)
assert result.success is False
assert result.deleted_from_database is False
assert "not found" in result.message.lower()
# ------------------------------------------------------------------
# Path traversal protection
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_series_path_traversal_blocked(self, anime_service, tmp_path):
"""delete_series refuses to delete folder outside anime base directory."""
# Create a safe folder at tmp_path, use parent as dangerous target
safe_base = tmp_path / "anime_root"
safe_base.mkdir()
dangerous_target = tmp_path.parent # parent of anime root
anime_service._directory = str(safe_base)
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
mock_series = MagicMock()
mock_series.key = "test-key"
mock_series.name = "Test"
mock_series.folder = str(dangerous_target) # outside safe_base
mock_series.id = 1
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=mock_series,
):
result = await anime_service.delete_series(
key="test-key",
delete_database=False,
delete_folder=True,
)
# Folder should NOT be deleted
assert result.deleted_folder is False
assert result.folder_error is not None
assert "outside" in result.folder_error.lower()
# ------------------------------------------------------------------
# Error handling
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_series_folder_delete_error(self, anime_service, tmp_path):
"""delete_series handles folder deletion errors gracefully."""
series_folder = tmp_path / "Test Series"
series_folder.mkdir()
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
mock_series = MagicMock()
mock_series.key = "test-key"
mock_series.name = "Test Series"
mock_series.folder = str(series_folder)
mock_series.id = 1
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=mock_series,
), patch(
"shutil.rmtree",
side_effect=OSError("Permission denied"),
):
result = await anime_service.delete_series(
key="test-key",
delete_database=False,
delete_folder=True,
)
assert result.deleted_folder is False
assert result.folder_error is not None
@pytest.mark.asyncio
async def test_delete_series_db_error_still_reports_folder(
self, anime_service, tmp_path
):
"""Even if DB delete fails, folder deletion result is still reported."""
series_folder = tmp_path / "Test Series"
series_folder.mkdir()
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
mock_series = MagicMock()
mock_series.key = "test-key"
mock_series.name = "Test Series"
mock_series.folder = str(series_folder)
mock_series.id = 1
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=mock_series,
), patch(
"src.server.database.service.AnimeSeriesService.delete",
new_callable=AsyncMock,
side_effect=Exception("DB connection lost"),
):
result = await anime_service.delete_series(
key="test-key",
delete_database=True,
delete_folder=True,
)
# DB failed but folder still deleted
assert result.deleted_from_database is False
assert result.database_error is not None
assert result.deleted_folder is True
assert not series_folder.exists()
# ------------------------------------------------------------------
# Edge cases
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_series_folder_none_when_no_folder(
self, anime_service
):
"""delete_series with folder=True but no folder path skips folder delete."""
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
mock_series = MagicMock()
mock_series.key = "test-key"
mock_series.name = "Test Series"
mock_series.folder = None
mock_series.id = 1
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=mock_series,
), patch(
"src.server.database.service.AnimeSeriesService.delete",
new_callable=AsyncMock,
return_value=True,
):
result = await anime_service.delete_series(
key="test-key",
delete_database=True,
delete_folder=True,
)
assert result.success is True
assert result.deleted_from_database is True
assert result.deleted_folder is False
assert result.folder_error is None # No folder → no error
@pytest.mark.asyncio
async def test_delete_series_neither_flag_set(self, anime_service):
"""delete_series returns failure when neither flag is True.
Needs DB patches so get_db_session doesn't raise RuntimeError,
even though this test hits the early-return guard before any DB use.
"""
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=None,
):
result = await anime_service.delete_series(
key="any-key",
delete_database=False,
delete_folder=False,
)
assert result.success is False
assert "at least one" in result.message.lower()
# ------------------------------------------------------------------
# WebSocket broadcast
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_series_websocket_broadcast(self, anime_service):
"""delete_series broadcasts series_deleted WebSocket event."""
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
mock_series = MagicMock()
mock_series.key = "test-key"
mock_series.name = "Test Series"
mock_series.folder = "Test Series (2023)"
mock_series.id = 42
anime_service._websocket_service = MagicMock()
anime_service._websocket_service.broadcast_series_deleted = AsyncMock()
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=mock_series,
), patch(
"src.server.database.service.AnimeSeriesService.delete",
new_callable=AsyncMock,
return_value=True,
):
await anime_service.delete_series(
key="test-key",
delete_database=True,
delete_folder=False,
)
anime_service._websocket_service.broadcast_series_deleted.assert_called_once_with(
key="test-key",
name="Test Series",
)