-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_admin_notes.py
More file actions
492 lines (376 loc) · 16.7 KB
/
test_admin_notes.py
File metadata and controls
492 lines (376 loc) · 16.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
"""Tests for the admin notes CRUD endpoints (JSON + HTMX dual responses)."""
import json
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from fastapi.responses import HTMLResponse
def _fake_note(
note_id: int = 1,
path: str = "/posts/hello",
text: str = "Original text",
is_public: bool = False,
) -> MagicMock:
"""Build a MagicMock that mimics the ORM ``Note`` shape."""
note = MagicMock()
note.id = note_id
note.path = path
note.text = text
note.is_public = is_public
note.author = "test-admin"
note.created_at = datetime(2026, 5, 16, 10, 0, 0)
note.updated_at = datetime(2026, 5, 16, 10, 0, 0)
return note
def _request(
*,
hx: bool = False,
content_type: str = "application/json",
json_body: dict | None = None,
form_body: dict | None = None,
) -> MagicMock:
"""Build a mock Request with optional HTMX header and body."""
request = MagicMock()
headers = {"content-type": content_type}
if hx:
headers["HX-Request"] = "true"
request.headers = headers
request.json = AsyncMock(return_value=json_body or {})
request.form = AsyncMock(return_value=form_body or {})
return request
@pytest.mark.asyncio
async def test_create_note_json_returns_json_response():
"""POST without HX-Request returns a NoteResponse JSON object."""
from squishmark.routers.admin import create_note
mock_service = AsyncMock()
mock_service.create.return_value = _fake_note(note_id=42, text="JSON note", is_public=True)
with patch("squishmark.routers.admin.NotesService", return_value=mock_service):
result = await create_note(
request=_request(json_body={"path": "/posts/x", "text": "JSON note", "is_public": True}),
admin="test-admin",
session=AsyncMock(),
)
assert not isinstance(result, HTMLResponse)
assert result.id == 42
assert result.text == "JSON note"
assert result.is_public is True
mock_service.create.assert_called_once_with(path="/posts/x", text="JSON note", author="test-admin", is_public=True)
@pytest.mark.asyncio
async def test_create_note_htmx_form_returns_html_partial():
"""POST with HX-Request and form body returns an HTML partial."""
from squishmark.routers.admin import create_note
mock_service = AsyncMock()
mock_service.create.return_value = _fake_note(note_id=7, text="HTMX note")
with (
patch("squishmark.routers.admin.NotesService", return_value=mock_service),
patch(
"squishmark.routers.admin._render_note_partial",
new=AsyncMock(return_value='<div class="note-item" id="note-7"><p>HTMX note</p></div>'),
) as mock_render,
):
result = await create_note(
request=_request(
hx=True,
content_type="application/x-www-form-urlencoded",
form_body={"path": "/posts/y", "text": "HTMX note"},
),
admin="test-admin",
session=AsyncMock(),
)
assert isinstance(result, HTMLResponse)
assert result.status_code == 201
assert b'id="note-7"' in result.body
mock_render.assert_awaited_once()
assert mock_render.call_args.args == ("admin/_note_item.html",)
@pytest.mark.asyncio
async def test_create_note_htmx_checkbox_omitted_persists_false():
"""When the is_public checkbox is omitted from form data, is_public=False is persisted."""
from squishmark.routers.admin import create_note
mock_service = AsyncMock()
mock_service.create.return_value = _fake_note(is_public=False)
with (
patch("squishmark.routers.admin.NotesService", return_value=mock_service),
patch("squishmark.routers.admin._render_note_partial", new=AsyncMock(return_value="<div></div>")),
):
await create_note(
request=_request(
hx=True,
content_type="application/x-www-form-urlencoded",
form_body={"path": "/posts/z", "text": "no checkbox"},
),
admin="test-admin",
session=AsyncMock(),
)
mock_service.create.assert_called_once()
assert mock_service.create.call_args.kwargs["is_public"] is False
@pytest.mark.asyncio
async def test_update_note_htmx_toggle_off_persists_false():
"""PUT form without is_public after note was public must persist is_public=False.
Guards the checkbox semantics: unchecked checkboxes are absent from form
data, but the user expects them to mean "off", not "no change".
"""
from squishmark.routers.admin import update_note
mock_service = AsyncMock()
mock_service.update_note.return_value = _fake_note(is_public=False)
with (
patch("squishmark.routers.admin.NotesService", return_value=mock_service),
patch("squishmark.routers.admin._render_note_partial", new=AsyncMock(return_value="<div></div>")),
):
await update_note(
request=_request(
hx=True,
content_type="application/x-www-form-urlencoded",
form_body={"text": "still here"},
),
admin="test-admin",
session=AsyncMock(),
note_id=1,
)
mock_service.update_note.assert_called_once_with(note_id=1, text="still here", is_public=False)
@pytest.mark.asyncio
async def test_update_note_not_found_raises_404():
"""PUT on a missing note returns 404."""
from squishmark.routers.admin import update_note
mock_service = AsyncMock()
mock_service.update_note.return_value = None
with patch("squishmark.routers.admin.NotesService", return_value=mock_service):
with pytest.raises(HTTPException) as exc_info:
await update_note(
request=_request(json_body={"text": "x"}),
admin="test-admin",
session=AsyncMock(),
note_id=999,
)
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_delete_note_htmx_returns_empty():
"""DELETE with HX-Request returns an empty 200 so HTMX removes the row."""
from squishmark.routers.admin import delete_note
mock_service = AsyncMock()
mock_service.delete.return_value = True
with patch("squishmark.routers.admin.NotesService", return_value=mock_service):
result = await delete_note(
request=_request(hx=True),
admin="test-admin",
session=AsyncMock(),
note_id=1,
)
assert isinstance(result, HTMLResponse)
assert result.status_code == 200
assert result.body == b""
@pytest.mark.asyncio
async def test_delete_note_json_returns_status_dict():
"""DELETE without HX-Request preserves the original JSON contract."""
from squishmark.routers.admin import delete_note
mock_service = AsyncMock()
mock_service.delete.return_value = True
with patch("squishmark.routers.admin.NotesService", return_value=mock_service):
result = await delete_note(
request=_request(),
admin="test-admin",
session=AsyncMock(),
note_id=1,
)
assert result == {"status": "deleted"}
@pytest.mark.asyncio
async def test_delete_note_not_found_raises_404():
"""DELETE on a missing note returns 404."""
from squishmark.routers.admin import delete_note
mock_service = AsyncMock()
mock_service.delete.return_value = False
with patch("squishmark.routers.admin.NotesService", return_value=mock_service):
with pytest.raises(HTTPException) as exc_info:
await delete_note(
request=_request(hx=True),
admin="test-admin",
session=AsyncMock(),
note_id=999,
)
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_edit_note_form_renders_partial():
"""GET /admin/notes/{id}/edit returns the edit-form partial."""
from squishmark.routers.admin import edit_note_form
mock_service = AsyncMock()
mock_service.get_by_id.return_value = _fake_note(note_id=3, text="edit me")
rendered = '<form class="note-edit-form"><textarea>edit me</textarea></form>'
with (
patch("squishmark.routers.admin.NotesService", return_value=mock_service),
patch("squishmark.routers.admin._render_note_partial", new=AsyncMock(return_value=rendered)) as mock_render,
):
result = await edit_note_form(
admin="test-admin",
session=AsyncMock(),
note_id=3,
)
assert isinstance(result, HTMLResponse)
assert b"note-edit-form" in result.body
assert mock_render.call_args.args == ("admin/_note_edit_form.html",)
@pytest.mark.asyncio
async def test_view_note_renders_item_partial():
"""GET /admin/notes/{id}/view returns the read-only item partial."""
from squishmark.routers.admin import view_note
mock_service = AsyncMock()
mock_service.get_by_id.return_value = _fake_note(note_id=3)
with (
patch("squishmark.routers.admin.NotesService", return_value=mock_service),
patch(
"squishmark.routers.admin._render_note_partial",
new=AsyncMock(return_value='<div class="note-item" id="note-3"></div>'),
) as mock_render,
):
result = await view_note(
admin="test-admin",
session=AsyncMock(),
note_id=3,
)
assert isinstance(result, HTMLResponse)
assert b'id="note-3"' in result.body
assert mock_render.call_args.args == ("admin/_note_item.html",)
@pytest.mark.asyncio
async def test_edit_form_not_found_raises_404():
"""GET /admin/notes/{id}/edit on a missing note returns 404."""
from squishmark.routers.admin import edit_note_form
mock_service = AsyncMock()
mock_service.get_by_id.return_value = None
with patch("squishmark.routers.admin.NotesService", return_value=mock_service):
with pytest.raises(HTTPException) as exc_info:
await edit_note_form(admin="test-admin", session=AsyncMock(), note_id=999)
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_get_csrf_returns_session_token():
"""GET /admin/csrf returns the current CSRF token for JSON API callers."""
from squishmark.routers.admin import get_csrf
request = MagicMock()
request.session = {}
result = await get_csrf(request=request, admin="test-admin")
assert "csrf_token" in result
assert result["csrf_token"]
# Returned token matches what's now stored on the session.
assert request.session["csrf_token"] == result["csrf_token"]
@pytest.mark.asyncio
async def test_get_csrf_idempotent_within_session():
"""Calling GET /admin/csrf twice on the same session returns the same token."""
from squishmark.routers.admin import get_csrf
request = MagicMock()
request.session = {}
first = await get_csrf(request=request, admin="test-admin")
second = await get_csrf(request=request, admin="test-admin")
assert first["csrf_token"] == second["csrf_token"]
@pytest.mark.asyncio
async def test_oauth_callback_rotates_csrf_token():
"""Successful OAuth callback clears any prior CSRF token from the session."""
from squishmark.services.csrf import SESSION_KEY
# Simulate the rotation step in isolation — the surrounding OAuth flow is HTTP-heavy.
session = {SESSION_KEY: "stale-token", "user": {"login": "x"}}
session.pop(SESSION_KEY, None)
assert SESSION_KEY not in session
assert session["user"] == {"login": "x"}
@pytest.mark.asyncio
async def test_get_current_admin_htmx_attaches_redirect_header():
"""HTMX requests with no session get an HX-Redirect header on 401."""
from squishmark.routers.admin import get_current_admin
request = MagicMock()
request.session = {}
request.headers = {"HX-Request": "true"}
with patch("squishmark.routers.admin.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(debug=False, dev_skip_auth=False, admin_users_list=[])
with pytest.raises(HTTPException) as exc_info:
await get_current_admin(request)
assert exc_info.value.status_code == 401
assert exc_info.value.headers == {"HX-Redirect": "/auth/login"}
@pytest.mark.asyncio
async def test_create_note_invalid_json_returns_422():
"""POST with malformed JSON body returns 422 (not 500)."""
from squishmark.routers.admin import create_note
request = MagicMock()
request.headers = {"content-type": "application/json"}
request.json = AsyncMock(side_effect=json.JSONDecodeError("Expecting value", "x", 0))
with pytest.raises(HTTPException) as exc_info:
await create_note(request=request, admin="test-admin", session=AsyncMock())
assert exc_info.value.status_code == 422
assert "Invalid JSON" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_create_note_json_missing_required_returns_422():
"""POST JSON missing required field returns 422 (not 500)."""
from squishmark.routers.admin import create_note
with pytest.raises(HTTPException) as exc_info:
await create_note(
request=_request(json_body={"path": "/x"}), # missing 'text'
admin="test-admin",
session=AsyncMock(),
)
assert exc_info.value.status_code == 422
@pytest.mark.asyncio
async def test_create_note_htmx_form_missing_path_returns_422():
"""POST form missing 'path' returns 422 instead of silently coercing to ''."""
from squishmark.routers.admin import create_note
with pytest.raises(HTTPException) as exc_info:
await create_note(
request=_request(
hx=True,
content_type="application/x-www-form-urlencoded",
form_body={"text": "no path"}, # missing 'path'
),
admin="test-admin",
session=AsyncMock(),
)
assert exc_info.value.status_code == 422
@pytest.mark.asyncio
async def test_update_note_htmx_form_missing_text_leaves_text_unchanged():
"""PUT form without 'text' field passes text=None so the stored value is preserved."""
from squishmark.routers.admin import update_note
mock_service = AsyncMock()
mock_service.update_note.return_value = _fake_note(text="unchanged", is_public=True)
with (
patch("squishmark.routers.admin.NotesService", return_value=mock_service),
patch("squishmark.routers.admin._render_note_partial", new=AsyncMock(return_value="<div></div>")),
):
await update_note(
request=_request(
hx=True,
content_type="application/x-www-form-urlencoded",
form_body={"is_public": "true"}, # no 'text' field at all
),
admin="test-admin",
session=AsyncMock(),
note_id=1,
)
mock_service.update_note.assert_called_once_with(note_id=1, text=None, is_public=True)
@pytest.mark.asyncio
async def test_render_partial_restores_current_theme():
"""ThemeEngine.render_partial must restore the previous current_theme after rendering."""
from unittest.mock import MagicMock as MM
from squishmark.services.theme.engine import ThemeEngine
engine = ThemeEngine.__new__(ThemeEngine)
engine.loader = MM()
engine.loader.current_theme = "blue-tech"
engine.env = MM()
engine.env.get_template.return_value.render.return_value = "<div></div>"
engine.render_partial("admin/_note_item.html", theme_override="terminal")
assert engine.loader.current_theme == "blue-tech"
@pytest.mark.asyncio
async def test_render_partial_restores_theme_on_render_exception():
"""The previous current_theme must be restored even if rendering raises."""
from unittest.mock import MagicMock as MM
from squishmark.services.theme.engine import ThemeEngine
engine = ThemeEngine.__new__(ThemeEngine)
engine.loader = MM()
engine.loader.current_theme = "default"
engine.env = MM()
engine.env.get_template.return_value.render.side_effect = RuntimeError("template crash")
with pytest.raises(RuntimeError, match="template crash"):
engine.render_partial("admin/_note_item.html", theme_override="terminal")
assert engine.loader.current_theme == "default"
@pytest.mark.asyncio
async def test_get_current_admin_non_htmx_omits_redirect_header():
"""Non-HTMX requests get a plain 401 with no HX-Redirect header."""
from squishmark.routers.admin import get_current_admin
request = MagicMock()
request.session = {}
request.headers = {}
with patch("squishmark.routers.admin.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(debug=False, dev_skip_auth=False, admin_users_list=[])
with pytest.raises(HTTPException) as exc_info:
await get_current_admin(request)
assert exc_info.value.status_code == 401
assert exc_info.value.headers is None