55from django .test import TestCase
66from django_dynamic_fixture import get
77
8- from readthedocs .builds .constants import BUILD_STATE_FINISHED , LATEST
8+ from readthedocs .builds .constants import BUILD_STATE_FINISHED , EXTERNAL , LATEST
99from readthedocs .builds .models import Build , Version
10- from readthedocs .filetreediff import get_diff
10+ from readthedocs .filetreediff import get_diff , snapshot_base_manifest
1111from readthedocs .projects .models import Project
1212from readthedocs .rtd_tests .storage import BuildMediaFileSystemStorageTest
1313
1414
15+ def _mock_open (content ):
16+ @contextmanager
17+ def f (* args , ** kwargs ):
18+ read_mock = mock .MagicMock ()
19+ read_mock .read .return_value = content
20+ yield read_mock
21+
22+ return f
23+
24+
25+ def _mock_manifest (build_id : int , files : dict [str , str ]):
26+ return _mock_open (
27+ json .dumps (
28+ {
29+ "build" : {"id" : build_id },
30+ "files" : {
31+ path : {"main_content_hash" : hash }
32+ for path , hash in files .items ()
33+ },
34+ }
35+ )
36+ )
37+
38+
1539# We are overriding the storage class instead of using RTD_BUILD_MEDIA_STORAGE,
1640# since the setting is evaluated just once (first test to use the storage
1741# backend will set it for the whole test suite).
@@ -59,37 +83,15 @@ def setUp(self):
5983 success = True ,
6084 )
6185
62- def _mock_open (self , content ):
63- @contextmanager
64- def f (* args , ** kwargs ):
65- read_mock = mock .MagicMock ()
66- read_mock .read .return_value = content
67- yield read_mock
68-
69- return f
70-
71- def _mock_manifest (self , build_id : int , files : dict [str , str ]):
72- return self ._mock_open (
73- json .dumps (
74- {
75- "build" : {"id" : build_id },
76- "files" : {
77- file_path : {"main_content_hash" : main_content_hash }
78- for file_path , main_content_hash in files .items ()
79- },
80- }
81- )
82- )
83-
8486 @mock .patch .object (BuildMediaFileSystemStorageTest , "open" )
8587 def test_diff_no_changes (self , storage_open ):
8688 files_a = {
8789 "index.html" : "hash1" ,
8890 "tutorials/index.html" : "hash2" ,
8991 }
9092 storage_open .side_effect = [
91- self . _mock_manifest (self .build_a .id , files_a )(),
92- self . _mock_manifest (self .build_b .id , files_a )(),
93+ _mock_manifest (self .build_a .id , files_a )(),
94+ _mock_manifest (self .build_b .id , files_a )(),
9395 ]
9496 diff = get_diff (self .version_a , self .version_b )
9597 assert diff .added == []
@@ -110,8 +112,8 @@ def test_diff_changes(self, storage_open):
110112 "deleted.html" : "hash-deleted" ,
111113 }
112114 storage_open .side_effect = [
113- self . _mock_manifest (self .build_a .id , files_a )(),
114- self . _mock_manifest (self .build_b .id , files_b )(),
115+ _mock_manifest (self .build_a .id , files_a )(),
116+ _mock_manifest (self .build_b .id , files_b )(),
115117 ]
116118 diff = get_diff (self .version_a , self .version_b )
117119 assert [file .path for file in diff .files ] == ["deleted.html" , "new-file.html" , "tutorials/index.html" ]
@@ -139,12 +141,116 @@ def test_outdated_diff(self, storage_open):
139141 "deleted.html" : "hash-deleted" ,
140142 }
141143 storage_open .side_effect = [
142- self . _mock_manifest (self .build_a_old .id , files_a )(),
143- self . _mock_manifest (self .build_b_old .id , files_b )(),
144+ _mock_manifest (self .build_a_old .id , files_a )(),
145+ _mock_manifest (self .build_b_old .id , files_b )(),
144146 ]
145147 diff = get_diff (self .version_a , self .version_b )
146148 assert [file .path for file in diff .files ] == ["deleted.html" , "new-file.html" , "tutorials/index.html" ]
147149 assert [file .path for file in diff .added ] == ["new-file.html" ]
148150 assert [file .path for file in diff .deleted ] == ["deleted.html" ]
149151 assert [file .path for file in diff .modified ] == ["tutorials/index.html" ]
150152 assert diff .outdated
153+
154+
155+ @mock .patch (
156+ "readthedocs.filetreediff.build_media_storage" ,
157+ new = BuildMediaFileSystemStorageTest (),
158+ )
159+ class TestsBaseManifestSnapshot (TestCase ):
160+ """Tests for base manifest snapshotting (stale branch fix)."""
161+
162+ def setUp (self ):
163+ self .project = get (Project )
164+ self .base_version = self .project .versions .get (slug = LATEST )
165+ self .base_build = get (
166+ Build ,
167+ project = self .project ,
168+ version = self .base_version ,
169+ state = BUILD_STATE_FINISHED ,
170+ success = True ,
171+ )
172+ self .pr_version = get (
173+ Version ,
174+ project = self .project ,
175+ slug = "pr-42" ,
176+ verbose_name = "42" ,
177+ type = EXTERNAL ,
178+ active = True ,
179+ built = True ,
180+ )
181+ self .pr_build = get (
182+ Build ,
183+ project = self .project ,
184+ version = self .pr_version ,
185+ state = BUILD_STATE_FINISHED ,
186+ success = True ,
187+ )
188+
189+ @mock .patch .object (BuildMediaFileSystemStorageTest , "open" )
190+ def test_snapshot_used_over_live_base (self , storage_open ):
191+ """For PRs, get_diff uses the snapshot instead of the live base manifest."""
192+ pr_files = {"index.html" : "pr-hash" , "new-page.html" : "new-hash" }
193+ snapshot_files = {"index.html" : "original-hash" }
194+
195+ # get_diff reads: 1) PR manifest, 2) base snapshot
196+ storage_open .side_effect = [
197+ _mock_manifest (self .pr_build .id , pr_files )(),
198+ _mock_manifest (self .base_build .id , snapshot_files )(),
199+ ]
200+ diff = get_diff (self .pr_version , self .base_version )
201+ assert [f .path for f in diff .added ] == ["new-page.html" ]
202+ assert [f .path for f in diff .modified ] == ["index.html" ]
203+ assert diff .deleted == []
204+
205+ @mock .patch .object (BuildMediaFileSystemStorageTest , "open" )
206+ def test_fallback_to_live_base_when_no_snapshot (self , storage_open ):
207+ pr_files = {"index.html" : "pr-hash" }
208+ live_base_files = {"index.html" : "live-hash" }
209+
210+ # 1) PR manifest, 2) snapshot miss, 3) live base manifest
211+ storage_open .side_effect = [
212+ _mock_manifest (self .pr_build .id , pr_files )(),
213+ FileNotFoundError ,
214+ _mock_manifest (self .base_build .id , live_base_files )(),
215+ ]
216+ diff = get_diff (self .pr_version , self .base_version )
217+ assert [f .path for f in diff .modified ] == ["index.html" ]
218+
219+ @mock .patch .object (BuildMediaFileSystemStorageTest , "open" )
220+ def test_snapshot_prevents_false_positives_from_stale_base (self , storage_open ):
221+ """
222+ PR only changed index.html. Meanwhile base added extra.html.
223+ Without snapshot the diff would show extra.html as deleted — bogus.
224+ """
225+ pr_files = {"index.html" : "pr-hash" , "about.html" : "same-hash" }
226+ snapshot_files = {"index.html" : "original-hash" , "about.html" : "same-hash" }
227+
228+ storage_open .side_effect = [
229+ _mock_manifest (self .pr_build .id , pr_files )(),
230+ _mock_manifest (self .base_build .id , snapshot_files )(),
231+ ]
232+ diff = get_diff (self .pr_version , self .base_version )
233+ assert [f .path for f in diff .modified ] == ["index.html" ]
234+ assert diff .added == []
235+ assert diff .deleted == []
236+
237+ @mock .patch .object (BuildMediaFileSystemStorageTest , "open" )
238+ def test_snapshot_is_write_once (self , storage_open ):
239+ """snapshot_base_manifest is a no-op if a snapshot already exists."""
240+ existing = json .dumps (
241+ {"build" : {"id" : 1 }, "files" : {"index.html" : {"main_content_hash" : "old" }}}
242+ )
243+
244+ @contextmanager
245+ def mock_existing (* a , ** kw ):
246+ m = mock .MagicMock ()
247+ m .read .return_value = existing
248+ yield m
249+
250+ storage_open .return_value = mock_existing ()
251+ snapshot_base_manifest (self .pr_version , self .base_version )
252+
253+ for call in storage_open .call_args_list :
254+ args , _ = call
255+ if len (args ) > 1 :
256+ assert args [1 ] != "w" , "Should not write when snapshot exists"
0 commit comments