FossilRepo

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

Keyboard Shortcuts

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