ixg_platform/apps/guides/tests.py
ixgadmin 6508cc25ef Add User Guides Viewer (Help Centre) - role-gated wiki help pages
- New apps/guides app with GuidePage model and audience mapping by wiki path
- sync_guides management command: one-way read-only sync from wikijs DB
- Read-only wikijs_ro DB role + WIKI_DB_* env config (secrets, gitignored)
- Markdown rendering with bleach sanitisation; rewrites internal guide links
  to the portal help root and wiki image paths to WIKI_PUBLIC_BASE
- /portal/help/ (buyer) and /ops/help/ (ops) index + detail views with
  role-based audience filtering (404 on cross-role guides)
- Help nav item added to both portal base templates
- Tests: audience filtering, markdown sanitisation, link rewriting, sync command
2026-08-07 14:19:49 +00:00

216 lines
8.4 KiB
Python

from unittest import mock
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.test import TestCase, override_settings
from django.utils import timezone
from .markdown import render_markdown
from .models import GuidePage
User = get_user_model()
def make_user(email, role):
return User.objects.create_user(
email=email, password="pass1234", role=role,
full_name=email.split("@")[0], is_staff=True,
)
def make_page(path, content="# Heading\n\nBody text", title=None):
return GuidePage.objects.create(
path=path,
title=title or path.rsplit("/", 1)[-1],
audience=GuidePage.map_audience(path),
content=content,
updated_at=timezone.now(),
)
class GuideAudienceFilterTests(TestCase):
def setUp(self):
self.shared = make_page("user-guides/index", "# User Guides\n\nWelcome.", title="Landing Page")
self.buyer = make_page("user-guides/buyer/getting-started", title="Getting Started Guide")
self.buyer2 = make_page("user-guides/buyer/dashboard", title="Dashboard Guide")
self.ops = make_page("user-guides/ops/batches", title="Ops Batches Guide")
self.admin = make_page("user-guides/admin/overview", title="Admin Overview Guide")
self.buyer_user = make_user("buyer@ixg.local", "buyer")
self.ops_user = make_user("ops@ixg.local", "ops")
self.admin_user = make_user("admin@ixg.local", "admin")
def test_buyer_index_shows_shared_and_buyer_only(self):
self.client.force_login(self.buyer_user)
resp = self.client.get("/portal/help/")
self.assertEqual(resp.status_code, 200)
content = resp.content.decode()
self.assertContains(resp, self.shared.title)
self.assertContains(resp, self.buyer.title)
self.assertContains(resp, self.buyer2.title)
self.assertNotContains(resp, self.ops.title)
self.assertNotContains(resp, self.admin.title)
def test_ops_index_shows_shared_and_ops_only(self):
self.client.force_login(self.ops_user)
resp = self.client.get("/ops/help/")
self.assertEqual(resp.status_code, 200)
content = resp.content.decode()
self.assertContains(resp, self.shared.title)
self.assertContains(resp, self.ops.title)
self.assertNotContains(resp, self.buyer.title)
self.assertNotContains(resp, self.admin.title)
def test_admin_index_sees_all(self):
self.client.force_login(self.admin_user)
for url in ("/portal/help/", "/ops/help/"):
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200, msg=url)
self.assertContains(resp, self.buyer.title, msg_prefix=url)
self.assertContains(resp, self.ops.title, msg_prefix=url)
self.assertContains(resp, self.admin.title, msg_prefix=url)
def test_buyer_cannot_access_ops_or_admin_guide(self):
self.client.force_login(self.buyer_user)
resp = self.client.get("/portal/help/ops/batches/")
self.assertEqual(resp.status_code, 404)
resp = self.client.get("/portal/help/admin/overview/")
self.assertEqual(resp.status_code, 404)
def test_ops_cannot_access_buyer_guide(self):
self.client.force_login(self.ops_user)
resp = self.client.get("/ops/help/buyer/getting-started/")
self.assertEqual(resp.status_code, 404)
def test_buyer_can_access_buyer_guide(self):
self.client.force_login(self.buyer_user)
resp = self.client.get("/portal/help/buyer/getting-started/")
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "<h1")
def test_admin_can_access_admin_guide_via_ops(self):
self.client.force_login(self.admin_user)
resp = self.client.get("/ops/help/admin/overview/")
self.assertEqual(resp.status_code, 200)
def test_unknown_guide_is_404(self):
self.client.force_login(self.buyer_user)
resp = self.client.get("/portal/help/nope/missing/")
self.assertEqual(resp.status_code, 404)
class GuideMarkdownTests(TestCase):
def test_script_tags_are_stripped(self):
html = render_markdown(
"# Title\n\nHello <script>alert('x')</script> world",
"user-guides/buyer/test",
"/portal/help/",
)
self.assertNotIn("<script", html)
def test_tables_and_heading_render(self):
html = render_markdown(
"| A | B |\n|---|---|\n| 1 | 2 |",
"user-guides/buyer/test",
"/portal/help/",
)
self.assertIn("<table", html)
self.assertIn("<th", html)
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
def test_same_folder_link_rewritten_to_help_root(self):
html = render_markdown(
"[Compliance guide](compliance)",
"user-guides/buyer/getting-started",
"/portal/help/",
)
self.assertIn('href="/portal/help/buyer/compliance/"', html)
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
def test_index_relative_link_rewritten(self):
html = render_markdown(
"[Getting started](buyer/getting-started)",
"user-guides/index",
"/ops/help/",
)
self.assertIn('href="/ops/help/buyer/getting-started/"', html)
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
def test_wiki_image_path_rewritten_to_public_base(self):
html = render_markdown(
"![Dashboard](/buyer-guide/buyer-dashboard.png)",
"user-guides/buyer/test",
"/portal/help/",
)
self.assertIn('src="http://wiki.test/buyer-guide/buyer-dashboard.png"', html)
def test_external_and_anchor_links_left_alone(self):
html = render_markdown(
"[Plan](/en/home/development/plan)\n\n[Top](#buyer-portal-guides)\n\n[Email](mailto:a@b.c)",
"user-guides/index",
"/portal/help/",
)
self.assertIn('href="/en/home/development/plan"', html)
self.assertIn('href="#buyer-portal-guides"', html)
self.assertIn('href="mailto:a@b.c"', html)
class FakeCursor:
def __init__(self, rows):
self.rows = rows
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def execute(self, sql, params=None):
pass
def fetchall(self):
return self.rows
class SyncGuidesCommandTests(TestCase):
def test_sync_maps_audience_and_upserts(self):
rows = [
("user-guides/index", "User Guides", "# landing", "2026-08-07T13:14:15.809Z"),
("user-guides/buyer/getting-started", "Getting Started", "# gs", "2026-08-07T13:25:30.479Z"),
("user-guides/ops/batches", "Batches", "# b", "2026-08-07T13:51:48.332Z"),
("user-guides/admin/overview", "Admin Overview", "# a", "2026-08-07T13:52:15.763Z"),
]
with mock.patch(
"apps.guides.management.commands.sync_guides.connections"
) as mock_connections:
mock_connections["wikijs"].cursor.return_value = FakeCursor(rows)
call_command("sync_guides")
self.assertEqual(GuidePage.objects.count(), 4)
self.assertEqual(
GuidePage.objects.get(path="user-guides/index").audience, GuidePage.Audience.SHARED
)
self.assertEqual(
GuidePage.objects.get(path="user-guides/buyer/getting-started").audience,
GuidePage.Audience.BUYER,
)
self.assertEqual(
GuidePage.objects.get(path="user-guides/ops/batches").audience, GuidePage.Audience.OPS
)
self.assertEqual(
GuidePage.objects.get(path="user-guides/admin/overview").audience, GuidePage.Audience.ADMIN
)
def test_sync_updates_changed_and_removes_stale(self):
make_page("user-guides/buyer/old", content="old content")
rows = [
("user-guides/buyer/getting-started", "Getting Started", "# fresh", "2026-08-07T13:25:30.479Z"),
]
with mock.patch(
"apps.guides.management.commands.sync_guides.connections"
) as mock_connections:
mock_connections["wikijs"].cursor.return_value = FakeCursor(rows)
call_command("sync_guides")
self.assertFalse(GuidePage.objects.filter(path="user-guides/buyer/old").exists())
page = GuidePage.objects.get(path="user-guides/buyer/getting-started")
self.assertEqual(page.content, "# fresh")