|
| 1 | +"""Unit tests for the write verification of store_entity(). |
| 2 | +
|
| 3 | +Regression guard for #175: store_entity() returned normally and listed a page in |
| 4 | +created_pages when that page did not exist on the wiki afterwards. An edit that |
| 5 | +raises nothing is not proof that the page was created, and page.changed is True |
| 6 | +in the failing case as well. store_entity() must now query the edited pages and |
| 7 | +report an absent one in result.failed. |
| 8 | +
|
| 9 | +These run fully offline: WtPage.init, the overwrite policy and the existence |
| 10 | +query are stubbed, so no network is required. |
| 11 | +""" |
| 12 | + |
| 13 | +import pytest |
| 14 | + |
| 15 | +import osw.core as core_mod |
| 16 | +import osw.model.entity as model |
| 17 | +from osw.core import OSW |
| 18 | +from osw.utils.wiki import get_namespace, get_title |
| 19 | +from osw.wtsite import WtPage |
| 20 | + |
| 21 | + |
| 22 | +def _title(entity): |
| 23 | + return f"{get_namespace(entity)}:{get_title(entity)}" |
| 24 | + |
| 25 | + |
| 26 | +class _FakeMwSite: |
| 27 | + """Records the API queries and answers them from a set of missing titles. |
| 28 | +
|
| 29 | + missing_once holds titles that are reported as missing by the first query |
| 30 | + only, which is what a read from a lagging database replica looks like. |
| 31 | + """ |
| 32 | + |
| 33 | + def __init__( |
| 34 | + self, missing_titles=(), normalized=None, missing_once=(), fails=False |
| 35 | + ): |
| 36 | + self.missing_titles = set(missing_titles) |
| 37 | + self.missing_once = set(missing_once) |
| 38 | + self.normalized = normalized or {} |
| 39 | + self.fails = fails |
| 40 | + self.queries = [] |
| 41 | + |
| 42 | + def api(self, action, **kwargs): |
| 43 | + assert action == "query" |
| 44 | + if self.fails: |
| 45 | + raise RuntimeError("the API is not reachable") |
| 46 | + titles = kwargs["titles"].split("|") |
| 47 | + self.queries.append(titles) |
| 48 | + missing_now = self.missing_titles | self.missing_once |
| 49 | + self.missing_once = set() |
| 50 | + pages = {} |
| 51 | + for i, title in enumerate(titles): |
| 52 | + reported = self.normalized.get(title, title) |
| 53 | + if title in missing_now: |
| 54 | + pages[str(-(i + 1))] = {"title": reported, "missing": ""} |
| 55 | + else: |
| 56 | + pages[str(i + 1)] = {"title": reported, "pageid": i + 1} |
| 57 | + query = {"pages": pages} |
| 58 | + if self.normalized: |
| 59 | + query["normalized"] = [ |
| 60 | + {"from": k, "to": v} for k, v in self.normalized.items() |
| 61 | + ] |
| 62 | + return {"query": query} |
| 63 | + |
| 64 | + |
| 65 | +class _FakeSite: |
| 66 | + def __init__(self, mw_site): |
| 67 | + self.mw_site = mw_site |
| 68 | + |
| 69 | + |
| 70 | +@pytest.fixture |
| 71 | +def offline_osw(monkeypatch): |
| 72 | + """An OSW that never touches the network, with a controllable existence query.""" |
| 73 | + monkeypatch.setattr(WtPage, "init", lambda self: setattr(self, "exists", False)) |
| 74 | + monkeypatch.setattr( |
| 75 | + OSW, "_apply_overwrite_policy", staticmethod(lambda param: param.page) |
| 76 | + ) |
| 77 | + monkeypatch.setattr(WtPage, "edit", lambda self, *a, **kw: None) |
| 78 | + # the delay before the confirmation query, not worth waiting for in a test |
| 79 | + monkeypatch.setattr(core_mod, "sleep", lambda *a, **kw: None) |
| 80 | + |
| 81 | + def _make(missing_titles=(), normalized=None, missing_once=(), fails=False): |
| 82 | + mw_site = _FakeMwSite(missing_titles, normalized, missing_once, fails) |
| 83 | + return OSW.construct(site=_FakeSite(mw_site)), mw_site |
| 84 | + |
| 85 | + return _make |
| 86 | + |
| 87 | + |
| 88 | +def test_absent_page_is_reported_as_failed(offline_osw): |
| 89 | + item = model.Item(label=[model.Label(text="Ghost")]) |
| 90 | + title = _title(item) |
| 91 | + osw_obj, _mw_site = offline_osw(missing_titles=[title]) |
| 92 | + |
| 93 | + with pytest.raises(OSW.StoreEntityPartialError) as exc_info: |
| 94 | + osw_obj.store_entity(OSW.StoreEntityParam(entities=[item], parallel=False)) |
| 95 | + |
| 96 | + err = exc_info.value |
| 97 | + assert title in err.failed |
| 98 | + assert isinstance(err.failed[title], OSW.PageNotCreatedError) |
| 99 | + assert err.failed[title].title == title |
| 100 | + assert title not in err.result.pages |
| 101 | + assert err.stored == [] |
| 102 | + |
| 103 | + |
| 104 | +def test_present_page_is_reported_as_stored(offline_osw): |
| 105 | + item = model.Item(label=[model.Label(text="Real")]) |
| 106 | + title = _title(item) |
| 107 | + osw_obj, mw_site = offline_osw() |
| 108 | + |
| 109 | + result = osw_obj.store_entity(OSW.StoreEntityParam(entities=[item], parallel=False)) |
| 110 | + |
| 111 | + assert set(result.pages.keys()) == {title} |
| 112 | + assert result.failed == {} |
| 113 | + assert mw_site.queries == [[title]] |
| 114 | + |
| 115 | + |
| 116 | +def test_only_the_absent_page_of_a_batch_is_reported(offline_osw): |
| 117 | + items = [model.Item(label=[model.Label(text=f"Batch{i}")]) for i in range(3)] |
| 118 | + titles = [_title(it) for it in items] |
| 119 | + osw_obj, _mw_site = offline_osw(missing_titles=[titles[1]]) |
| 120 | + |
| 121 | + with pytest.raises(OSW.StoreEntityPartialError) as exc_info: |
| 122 | + osw_obj.store_entity(OSW.StoreEntityParam(entities=items, parallel=True)) |
| 123 | + |
| 124 | + err = exc_info.value |
| 125 | + assert set(err.result.pages.keys()) == {titles[0], titles[2]} |
| 126 | + assert set(err.failed.keys()) == {titles[1]} |
| 127 | + |
| 128 | + |
| 129 | +def test_verification_is_skipped_when_disabled(offline_osw): |
| 130 | + item = model.Item(label=[model.Label(text="Unchecked")]) |
| 131 | + title = _title(item) |
| 132 | + osw_obj, mw_site = offline_osw(missing_titles=[title]) |
| 133 | + |
| 134 | + result = osw_obj.store_entity( |
| 135 | + OSW.StoreEntityParam(entities=[item], parallel=False, verify_write=False) |
| 136 | + ) |
| 137 | + |
| 138 | + assert mw_site.queries == [] |
| 139 | + assert set(result.pages.keys()) == {title} |
| 140 | + |
| 141 | + |
| 142 | +def test_verification_is_skipped_offline(offline_osw): |
| 143 | + item = model.Item(label=[model.Label(text="Offline")]) |
| 144 | + title = _title(item) |
| 145 | + osw_obj, mw_site = offline_osw(missing_titles=[title]) |
| 146 | + |
| 147 | + result = osw_obj.store_entity( |
| 148 | + OSW.StoreEntityParam(entities=[item], parallel=False, offline=True) |
| 149 | + ) |
| 150 | + |
| 151 | + assert mw_site.queries == [] |
| 152 | + assert set(result.pages.keys()) == {title} |
| 153 | + |
| 154 | + |
| 155 | +def test_a_missing_page_is_confirmed_by_a_second_query(offline_osw): |
| 156 | + item = model.Item(label=[model.Label(text="Ghost")]) |
| 157 | + title = _title(item) |
| 158 | + osw_obj, mw_site = offline_osw(missing_titles=[title]) |
| 159 | + |
| 160 | + with pytest.raises(OSW.StoreEntityPartialError): |
| 161 | + osw_obj.store_entity(OSW.StoreEntityParam(entities=[item], parallel=False)) |
| 162 | + |
| 163 | + assert mw_site.queries == [[title], [title]] |
| 164 | + |
| 165 | + |
| 166 | +def test_a_page_that_appears_on_the_second_query_is_not_reported(offline_osw): |
| 167 | + """A read answered by a lagging database replica must not fail the store.""" |
| 168 | + items = [model.Item(label=[model.Label(text=f"Lag{i}")]) for i in range(2)] |
| 169 | + titles = [_title(it) for it in items] |
| 170 | + osw_obj, mw_site = offline_osw(missing_once=[titles[0]]) |
| 171 | + |
| 172 | + result = osw_obj.store_entity(OSW.StoreEntityParam(entities=items, parallel=False)) |
| 173 | + |
| 174 | + assert set(result.pages.keys()) == set(titles) |
| 175 | + assert result.failed == {} |
| 176 | + # the second query asks only for the title the first one reported as missing |
| 177 | + assert mw_site.queries[1] == [titles[0]] |
| 178 | + |
| 179 | + |
| 180 | +def test_a_failing_query_keeps_the_pages_and_does_not_raise(offline_osw): |
| 181 | + items = [model.Item(label=[model.Label(text=f"Unverified{i}")]) for i in range(2)] |
| 182 | + titles = [_title(it) for it in items] |
| 183 | + osw_obj, _mw_site = offline_osw(fails=True) |
| 184 | + |
| 185 | + result = osw_obj.store_entity(OSW.StoreEntityParam(entities=items, parallel=False)) |
| 186 | + |
| 187 | + assert set(result.pages.keys()) == set(titles) |
| 188 | + assert result.failed == {} |
| 189 | + |
| 190 | + |
| 191 | +def test_titles_are_queried_in_batches_of_fifty(offline_osw): |
| 192 | + osw_obj, mw_site = offline_osw() |
| 193 | + titles = [f"Item:OSW{i:04d}" for i in range(120)] |
| 194 | + |
| 195 | + missing = osw_obj._get_missing_page_titles(titles) |
| 196 | + |
| 197 | + assert missing == [] |
| 198 | + assert [len(batch) for batch in mw_site.queries] == [50, 50, 20] |
| 199 | + |
| 200 | + |
| 201 | +def test_missing_titles_are_mapped_back_to_the_requested_form(offline_osw): |
| 202 | + requested = "Item:OSW_with_underscores" |
| 203 | + osw_obj, _mw_site = offline_osw( |
| 204 | + missing_titles=[requested], |
| 205 | + normalized={requested: "Item:OSW with underscores"}, |
| 206 | + ) |
| 207 | + |
| 208 | + assert osw_obj._get_missing_page_titles([requested]) == [requested] |
0 commit comments