FossilRepo

fossilrepo / tests / test_technotes.py
Source Blame History 383 lines
c588255… ragelink 1 import sqlite3
c588255… ragelink 2 from pathlib import Path
c588255… ragelink 3 from unittest.mock import MagicMock, patch
c588255… ragelink 4
c588255… ragelink 5 import pytest
c588255… ragelink 6
c588255… ragelink 7 from fossil.models import FossilRepository
c588255… ragelink 8 from fossil.reader import FossilReader
c588255… ragelink 9
c588255… ragelink 10 # Reusable patch that makes FossilRepository.exists_on_disk return True
c588255… ragelink 11 _disk_patch = patch("fossil.models.FossilRepository.exists_on_disk", new_callable=lambda: property(lambda self: True))
c588255… ragelink 12
c588255… ragelink 13
c588255… ragelink 14 @pytest.fixture
c588255… ragelink 15 def fossil_repo_obj(sample_project):
c588255… ragelink 16 """Return the auto-created FossilRepository for sample_project."""
c588255… ragelink 17 return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
c588255… ragelink 18
c588255… ragelink 19
c588255… ragelink 20 def _create_test_fossil_db(path: Path):
c588255… ragelink 21 """Create a minimal .fossil SQLite database with the tables reader.py needs."""
c588255… ragelink 22 conn = sqlite3.connect(str(path))
c588255… ragelink 23 conn.execute(
c588255… ragelink 24 """
c588255… ragelink 25 CREATE TABLE blob (
c588255… ragelink 26 rid INTEGER PRIMARY KEY,
c588255… ragelink 27 uuid TEXT UNIQUE NOT NULL,
c588255… ragelink 28 size INTEGER NOT NULL DEFAULT 0,
c588255… ragelink 29 content BLOB
c588255… ragelink 30 )
c588255… ragelink 31 """
c588255… ragelink 32 )
c588255… ragelink 33 conn.execute(
c588255… ragelink 34 """
c588255… ragelink 35 CREATE TABLE event (
c588255… ragelink 36 type TEXT,
c588255… ragelink 37 mtime REAL,
c588255… ragelink 38 objid INTEGER,
c588255… ragelink 39 user TEXT,
c588255… ragelink 40 comment TEXT
c588255… ragelink 41 )
c588255… ragelink 42 """
c588255… ragelink 43 )
c588255… ragelink 44 conn.commit()
c588255… ragelink 45 return conn
c588255… ragelink 46
c588255… ragelink 47
c588255… ragelink 48 def _insert_technote(conn, rid, uuid, mtime, user, comment, body_content=""):
c588255… ragelink 49 """Insert a technote event and blob into the test database.
c588255… ragelink 50
c588255… ragelink 51 Technotes use event.type = 'e'. The blob contains a Fossil wiki artifact
c588255… ragelink 52 format: header cards followed by W <size>\\n<content>\\nZ <hash>.
c588255… ragelink 53 """
c588255… ragelink 54 import struct
c588255… ragelink 55 import zlib
c588255… ragelink 56
c588255… ragelink 57 # Build a minimal Fossil wiki artifact containing the body
c588255… ragelink 58 artifact = f"D 2024-01-01T00:00:00\nU {user}\nW {len(body_content.encode('utf-8'))}\n{body_content}\nZ 0000000000000000"
c588255… ragelink 59 raw_bytes = artifact.encode("utf-8")
c588255… ragelink 60
c588255… ragelink 61 # Fossil stores blobs with a 4-byte big-endian size prefix + zlib compressed content
c588255… ragelink 62 compressed = struct.pack(">I", len(raw_bytes)) + zlib.compress(raw_bytes)
c588255… ragelink 63
c588255… ragelink 64 conn.execute("INSERT INTO blob (rid, uuid, size, content) VALUES (?, ?, ?, ?)", (rid, uuid, len(raw_bytes), compressed))
c588255… ragelink 65 conn.execute("INSERT INTO event (type, mtime, objid, user, comment) VALUES ('e', ?, ?, ?, ?)", (mtime, rid, user, comment))
c588255… ragelink 66 conn.commit()
c588255… ragelink 67
c588255… ragelink 68
c588255… ragelink 69 @pytest.fixture
c588255… ragelink 70 def fossil_db(tmp_path):
c588255… ragelink 71 """Create a temporary .fossil file with technote data for reader tests."""
c588255… ragelink 72 db_path = tmp_path / "test.fossil"
c588255… ragelink 73 conn = _create_test_fossil_db(db_path)
c588255… ragelink 74 _insert_technote(conn, 100, "abc123def456", 2460676.5, "admin", "First technote", "# Hello\n\nThis is the body.")
c588255… ragelink 75 _insert_technote(conn, 101, "xyz789ghi012", 2460677.5, "dev", "Second technote", "Another note body.")
c588255… ragelink 76 conn.close()
c588255… ragelink 77 return db_path
c588255… ragelink 78
c588255… ragelink 79
c588255… ragelink 80 def _make_reader_mock(**methods):
c588255… ragelink 81 """Create a MagicMock that replaces FossilReader as a class.
c588255… ragelink 82
c588255… ragelink 83 The returned mock supports:
c588255… ragelink 84 reader = FossilReader(path) # returns a mock instance
c588255… ragelink 85 with reader: # context manager
c588255… ragelink 86 reader.some_method() # returns configured value
c588255… ragelink 87 """
c588255… ragelink 88 mock_cls = MagicMock()
c588255… ragelink 89 # The instance returned by calling the class
c588255… ragelink 90 instance = MagicMock()
c588255… ragelink 91 mock_cls.return_value = instance
c588255… ragelink 92 # Context manager support: __enter__ returns the same instance
c588255… ragelink 93 instance.__enter__ = MagicMock(return_value=instance)
c588255… ragelink 94 instance.__exit__ = MagicMock(return_value=False)
c588255… ragelink 95 for name, val in methods.items():
c588255… ragelink 96 getattr(instance, name).return_value = val
c588255… ragelink 97 return mock_cls
c588255… ragelink 98
c588255… ragelink 99
c588255… ragelink 100 # --- Reader unit tests (no Django DB needed) ---
c588255… ragelink 101
c588255… ragelink 102
c588255… ragelink 103 class TestGetTechnotes:
c588255… ragelink 104 def test_returns_technotes(self, fossil_db):
c588255… ragelink 105 reader = FossilReader(fossil_db)
c588255… ragelink 106 with reader:
c588255… ragelink 107 notes = reader.get_technotes()
c588255… ragelink 108 assert len(notes) == 2
c588255… ragelink 109 assert notes[0]["uuid"] == "xyz789ghi012" # Most recent first
c588255… ragelink 110 assert notes[1]["uuid"] == "abc123def456"
c588255… ragelink 111
c588255… ragelink 112 def test_technote_fields(self, fossil_db):
c588255… ragelink 113 reader = FossilReader(fossil_db)
c588255… ragelink 114 with reader:
c588255… ragelink 115 notes = reader.get_technotes()
c588255… ragelink 116 note = notes[1] # The first inserted one
c588255… ragelink 117 assert note["user"] == "admin"
c588255… ragelink 118 assert note["comment"] == "First technote"
c588255… ragelink 119 assert note["timestamp"] is not None
c588255… ragelink 120
c588255… ragelink 121 def test_empty_repo(self, tmp_path):
c588255… ragelink 122 db_path = tmp_path / "empty.fossil"
c588255… ragelink 123 conn = _create_test_fossil_db(db_path)
c588255… ragelink 124 conn.close()
c588255… ragelink 125 reader = FossilReader(db_path)
c588255… ragelink 126 with reader:
c588255… ragelink 127 notes = reader.get_technotes()
c588255… ragelink 128 assert notes == []
c588255… ragelink 129
c588255… ragelink 130
c588255… ragelink 131 class TestGetTechnoteDetail:
c588255… ragelink 132 def test_returns_detail_with_body(self, fossil_db):
c588255… ragelink 133 reader = FossilReader(fossil_db)
c588255… ragelink 134 with reader:
c588255… ragelink 135 note = reader.get_technote_detail("abc123def456")
c588255… ragelink 136 assert note is not None
c588255… ragelink 137 assert note["uuid"] == "abc123def456"
c588255… ragelink 138 assert note["comment"] == "First technote"
c588255… ragelink 139 assert "# Hello" in note["body"]
c588255… ragelink 140 assert "This is the body." in note["body"]
c588255… ragelink 141
c588255… ragelink 142 def test_prefix_match(self, fossil_db):
c588255… ragelink 143 reader = FossilReader(fossil_db)
c588255… ragelink 144 with reader:
c588255… ragelink 145 note = reader.get_technote_detail("abc123")
c588255… ragelink 146 assert note is not None
c588255… ragelink 147 assert note["uuid"] == "abc123def456"
c588255… ragelink 148
c588255… ragelink 149 def test_not_found(self, fossil_db):
c588255… ragelink 150 reader = FossilReader(fossil_db)
c588255… ragelink 151 with reader:
c588255… ragelink 152 note = reader.get_technote_detail("nonexistent")
c588255… ragelink 153 assert note is None
c588255… ragelink 154
c588255… ragelink 155
c588255… ragelink 156 class TestGetUnversionedFiles:
c588255… ragelink 157 def test_returns_files(self, tmp_path):
c588255… ragelink 158 db_path = tmp_path / "uv.fossil"
c588255… ragelink 159 conn = _create_test_fossil_db(db_path)
c588255… ragelink 160 conn.execute(
c588255… ragelink 161 """
c588255… ragelink 162 CREATE TABLE unversioned (
c588255… ragelink 163 uvid INTEGER PRIMARY KEY AUTOINCREMENT,
c588255… ragelink 164 name TEXT UNIQUE,
c588255… ragelink 165 rcvid INTEGER,
c588255… ragelink 166 mtime DATETIME,
c588255… ragelink 167 hash TEXT,
c588255… ragelink 168 sz INTEGER,
c588255… ragelink 169 encoding INT,
c588255… ragelink 170 content BLOB
c588255… ragelink 171 )
c588255… ragelink 172 """
c588255… ragelink 173 )
c588255… ragelink 174 conn.execute(
c588255… ragelink 175 "INSERT INTO unversioned (name, mtime, hash, sz, encoding, content) VALUES (?, ?, ?, ?, ?, ?)",
c588255… ragelink 176 ("readme.txt", 1700000000, "abc123hash", 42, 0, b"file content"),
c588255… ragelink 177 )
c588255… ragelink 178 conn.execute(
c588255… ragelink 179 "INSERT INTO unversioned (name, mtime, hash, sz, encoding, content) VALUES (?, ?, ?, ?, ?, ?)",
c588255… ragelink 180 ("bin/app.tar.gz", 1700001000, "def456hash", 1024, 0, b"tarball"),
c588255… ragelink 181 )
c588255… ragelink 182 conn.commit()
c588255… ragelink 183 conn.close()
c588255… ragelink 184
c588255… ragelink 185 reader = FossilReader(db_path)
c588255… ragelink 186 with reader:
c588255… ragelink 187 files = reader.get_unversioned_files()
c588255… ragelink 188 assert len(files) == 2
c588255… ragelink 189 assert files[0]["name"] == "bin/app.tar.gz" # Alphabetical
c588255… ragelink 190 assert files[1]["name"] == "readme.txt"
c588255… ragelink 191 assert files[1]["size"] == 42
c588255… ragelink 192 assert files[1]["hash"] == "abc123hash"
c588255… ragelink 193 assert files[1]["mtime"] is not None
c588255… ragelink 194
c588255… ragelink 195 def test_no_unversioned_table(self, tmp_path):
c588255… ragelink 196 """Repos without unversioned content don't have the table -- should return empty."""
c588255… ragelink 197 db_path = tmp_path / "no_uv.fossil"
c588255… ragelink 198 conn = _create_test_fossil_db(db_path)
c588255… ragelink 199 conn.close()
c588255… ragelink 200 reader = FossilReader(db_path)
c588255… ragelink 201 with reader:
c588255… ragelink 202 files = reader.get_unversioned_files()
c588255… ragelink 203 assert files == []
c588255… ragelink 204
c588255… ragelink 205 def test_deleted_files_excluded(self, tmp_path):
c588255… ragelink 206 """Deleted UV files have empty hash -- should be excluded."""
c588255… ragelink 207 db_path = tmp_path / "del_uv.fossil"
c588255… ragelink 208 conn = _create_test_fossil_db(db_path)
c588255… ragelink 209 conn.execute(
c588255… ragelink 210 """
c588255… ragelink 211 CREATE TABLE unversioned (
c588255… ragelink 212 uvid INTEGER PRIMARY KEY AUTOINCREMENT,
c588255… ragelink 213 name TEXT UNIQUE, rcvid INTEGER, mtime DATETIME,
c588255… ragelink 214 hash TEXT, sz INTEGER, encoding INT, content BLOB
c588255… ragelink 215 )
c588255… ragelink 216 """
c588255… ragelink 217 )
c588255… ragelink 218 conn.execute(
c588255… ragelink 219 "INSERT INTO unversioned (name, mtime, hash, sz) VALUES (?, ?, ?, ?)",
c588255… ragelink 220 ("alive.txt", 1700000000, "somehash", 10),
c588255… ragelink 221 )
c588255… ragelink 222 conn.execute(
c588255… ragelink 223 "INSERT INTO unversioned (name, mtime, hash, sz) VALUES (?, ?, ?, ?)",
c588255… ragelink 224 ("deleted.txt", 1700000000, "", 0),
c588255… ragelink 225 )
c588255… ragelink 226 conn.commit()
c588255… ragelink 227 conn.close()
c588255… ragelink 228
c588255… ragelink 229 reader = FossilReader(db_path)
c588255… ragelink 230 with reader:
c588255… ragelink 231 files = reader.get_unversioned_files()
c588255… ragelink 232 assert len(files) == 1
c588255… ragelink 233 assert files[0]["name"] == "alive.txt"
c588255… ragelink 234
c588255… ragelink 235
c588255… ragelink 236 # --- View tests (Django DB needed) ---
c588255… ragelink 237
c588255… ragelink 238
c588255… ragelink 239 @pytest.mark.django_db
c588255… ragelink 240 class TestTechnoteListView:
c588255… ragelink 241 def test_list_page_loads(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 242 mock = _make_reader_mock(get_technotes=[{"uuid": "abc123", "timestamp": "2024-01-01", "user": "admin", "comment": "Test note"}])
c588255… ragelink 243 with _disk_patch, patch("fossil.views.FossilReader", mock):
c588255… ragelink 244 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/")
c588255… ragelink 245 assert response.status_code == 200
c588255… ragelink 246 content = response.content.decode()
c588255… ragelink 247 assert "Technotes" in content
c588255… ragelink 248 assert "Test note" in content
c588255… ragelink 249
c588255… ragelink 250 def test_list_shows_create_button_for_writer(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 251 mock = _make_reader_mock(get_technotes=[])
c588255… ragelink 252 with _disk_patch, patch("fossil.views.FossilReader", mock):
c588255… ragelink 253 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/")
c588255… ragelink 254 assert response.status_code == 200
c588255… ragelink 255 assert "New Technote" in response.content.decode()
c588255… ragelink 256
c588255… ragelink 257 def test_list_hides_create_button_for_reader(self, sample_project, fossil_repo_obj):
c588255… ragelink 258 from django.contrib.auth.models import User
c588255… ragelink 259 from django.test import Client
c588255… ragelink 260
c588255… ragelink 261 User.objects.create_user(username="reader_only", password="testpass123")
c588255… ragelink 262 c = Client()
c588255… ragelink 263 c.login(username="reader_only", password="testpass123")
c588255… ragelink 264 sample_project.visibility = "public"
c588255… ragelink 265 sample_project.save()
c588255… ragelink 266 mock = _make_reader_mock(get_technotes=[])
c588255… ragelink 267 with _disk_patch, patch("fossil.views.FossilReader", mock):
c588255… ragelink 268 response = c.get(f"/projects/{sample_project.slug}/fossil/technotes/")
c588255… ragelink 269 assert response.status_code == 200
c588255… ragelink 270 assert "New Technote" not in response.content.decode()
c588255… ragelink 271
c588255… ragelink 272 def test_list_denied_for_no_perm_on_private(self, no_perm_client, sample_project):
c588255… ragelink 273 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/technotes/")
c588255… ragelink 274 assert response.status_code == 403
c588255… ragelink 275
c588255… ragelink 276
c588255… ragelink 277 @pytest.mark.django_db
c588255… ragelink 278 class TestTechnoteCreateView:
c588255… ragelink 279 def test_get_form(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 280 with _disk_patch:
c588255… ragelink 281 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/create/")
c588255… ragelink 282 assert response.status_code == 200
c588255… ragelink 283 assert "New Technote" in response.content.decode()
c588255… ragelink 284
c588255… ragelink 285 def test_create_technote(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 286 mock_cli = MagicMock()
c588255… ragelink 287 mock_cli.return_value.technote_create.return_value = True
c588255… ragelink 288 with _disk_patch, patch("fossil.cli.FossilCLI", mock_cli):
c588255… ragelink 289 response = admin_client.post(
c588255… ragelink 290 f"/projects/{sample_project.slug}/fossil/technotes/create/",
c588255… ragelink 291 {"title": "My Note", "body": "Note body content", "timestamp": ""},
c588255… ragelink 292 )
c588255… ragelink 293 assert response.status_code == 302 # Redirect to list
c588255… ragelink 294
c588255… ragelink 295 def test_create_denied_for_anon(self, client, sample_project):
c588255… ragelink 296 response = client.get(f"/projects/{sample_project.slug}/fossil/technotes/create/")
c588255… ragelink 297 assert response.status_code == 302 # Redirect to login
c588255… ragelink 298
c588255… ragelink 299 def test_create_denied_for_no_perm(self, no_perm_client, sample_project):
c588255… ragelink 300 response = no_perm_client.post(
c588255… ragelink 301 f"/projects/{sample_project.slug}/fossil/technotes/create/",
c588255… ragelink 302 {"title": "Nope", "body": "denied"},
c588255… ragelink 303 )
c588255… ragelink 304 assert response.status_code == 403
c588255… ragelink 305
c588255… ragelink 306
c588255… ragelink 307 @pytest.mark.django_db
c588255… ragelink 308 class TestTechnoteDetailView:
c588255… ragelink 309 def test_detail_page(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 310 mock = _make_reader_mock(
c588255… ragelink 311 get_technote_detail={
c588255… ragelink 312 "uuid": "abc123def456",
c588255… ragelink 313 "timestamp": "2024-01-01",
c588255… ragelink 314 "user": "admin",
c588255… ragelink 315 "comment": "Test technote",
c588255… ragelink 316 "body": "# Hello\n\nBody text.",
c588255… ragelink 317 }
c588255… ragelink 318 )
c588255… ragelink 319 with _disk_patch, patch("fossil.views.FossilReader", mock):
c588255… ragelink 320 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/abc123def456/")
c588255… ragelink 321 assert response.status_code == 200
c588255… ragelink 322 content = response.content.decode()
c588255… ragelink 323 assert "Test technote" in content
c588255… ragelink 324 assert "Body text" in content
c588255… ragelink 325
c588255… ragelink 326 def test_detail_not_found(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 327 mock = _make_reader_mock(get_technote_detail=None)
c588255… ragelink 328 with _disk_patch, patch("fossil.views.FossilReader", mock):
c588255… ragelink 329 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/nonexistent/")
c588255… ragelink 330 assert response.status_code == 404
c588255… ragelink 331
c588255… ragelink 332 def test_detail_denied_for_no_perm_on_private(self, no_perm_client, sample_project):
c588255… ragelink 333 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/technotes/abc123/")
c588255… ragelink 334 assert response.status_code == 403
c588255… ragelink 335
c588255… ragelink 336
c588255… ragelink 337 @pytest.mark.django_db
c588255… ragelink 338 class TestTechnoteEditView:
c588255… ragelink 339 def test_get_edit_form(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 340 mock = _make_reader_mock(
c588255… ragelink 341 get_technote_detail={
c588255… ragelink 342 "uuid": "abc123def456",
c588255… ragelink 343 "timestamp": "2024-01-01",
c588255… ragelink 344 "user": "admin",
c588255… ragelink 345 "comment": "Test technote",
c588255… ragelink 346 "body": "Existing body content",
c588255… ragelink 347 }
c588255… ragelink 348 )
c588255… ragelink 349 with _disk_patch, patch("fossil.views.FossilReader", mock):
c588255… ragelink 350 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/abc123def456/edit/")
c588255… ragelink 351 assert response.status_code == 200
c588255… ragelink 352 content = response.content.decode()
c588255… ragelink 353 assert "Existing body content" in content
c588255… ragelink 354
c588255… ragelink 355 def test_edit_technote(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 356 mock = _make_reader_mock(
c588255… ragelink 357 get_technote_detail={
c588255… ragelink 358 "uuid": "abc123def456",
c588255… ragelink 359 "timestamp": "2024-01-01",
c588255… ragelink 360 "user": "admin",
c588255… ragelink 361 "comment": "Test technote",
c588255… ragelink 362 "body": "Old body",
c588255… ragelink 363 }
c588255… ragelink 364 )
c588255… ragelink 365 mock_cli = MagicMock()
c588255… ragelink 366 mock_cli.return_value.technote_edit.return_value = True
c588255… ragelink 367 with _disk_patch, patch("fossil.views.FossilReader", mock), patch("fossil.cli.FossilCLI", mock_cli):
c588255… ragelink 368 response = admin_client.post(
c588255… ragelink 369 f"/projects/{sample_project.slug}/fossil/technotes/abc123def456/edit/",
c588255… ragelink 370 {"body": "Updated body content"},
c588255… ragelink 371 )
c588255… ragelink 372 assert response.status_code == 302 # Redirect to detail
c588255… ragelink 373
c588255… ragelink 374 def test_edit_denied_for_anon(self, client, sample_project):
c588255… ragelink 375 response = client.get(f"/projects/{sample_project.slug}/fossil/technotes/abc123/edit/")
c588255… ragelink 376 assert response.status_code == 302 # Redirect to login
c588255… ragelink 377
c588255… ragelink 378 def test_edit_denied_for_no_perm(self, no_perm_client, sample_project):
c588255… ragelink 379 response = no_perm_client.post(
c588255… ragelink 380 f"/projects/{sample_project.slug}/fossil/technotes/abc123/edit/",
c588255… ragelink 381 {"body": "denied"},
c588255… ragelink 382 )
c588255… ragelink 383 assert response.status_code == 403

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button