Skip to content

Commit 4d2e8e2

Browse files
committed
Merge branch '5-cli-restore' of github.com:The-Strategy-Unit/nhp_ats_backup into 5-cli-restore
2 parents c0e60ad + 08a26a1 commit 4d2e8e2

4 files changed

Lines changed: 289 additions & 5 deletions

File tree

backup/cli.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def _download_snapshot(name: str) -> str:
5151
container = _get_env("BACKUP_CONTAINER_NAME")
5252
blob_service = _blob_client(cred)
5353
client = blob_service.get_blob_client(container=container, blob=name)
54-
local_path = name
54+
local_path = os.path.basename(name).replace("%3A", ":").replace(":", "_")
5555
with open(local_path, "wb") as f:
5656
f.write(client.download_blob().readall())
5757
print(f"Downloaded snapshot -> {local_path}")
@@ -159,6 +159,9 @@ def main() -> int:
159159
except KeyboardInterrupt:
160160
print("\nAborted.")
161161
return 130
162+
except Exception as e:
163+
print(f"Failed: {e}", file=sys.stderr)
164+
return 1
162165

163166

164167
if __name__ == "__main__":

backup/core.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,9 @@
5555
MAX_DAILY_SNAPSHOTS = 7
5656
MAX_MONTHLY_SNAPSHOTS = 6
5757

58-
# Entity Data Model (EDM) types supported by Azure Table Storage.
59-
# https://learn.microsoft.com/en-us/rest/api/searchservice/supported-data-types
58+
# Subset of Entity Data Model (EDM) types supported by this tool for
59+
# Azure Table Storage round-tripping.
60+
# https://learn.microsoft.com/en-us/rest/api/storageservices/understanding-the-table-service-data-model#property-types
6061
SUPPORTED_EDM_TYPES = {
6162
"Edm.DateTime",
6263
"Edm.Boolean",

tests/test_cli.py

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
"""Unit tests for backup.cli interactive behaviors."""
2+
3+
import json
4+
from unittest.mock import MagicMock, mock_open, patch
5+
6+
import pytest
7+
8+
from backup.cli import (
9+
_confirm,
10+
_download_snapshot,
11+
_find_snapshots,
12+
_resolve_snapshot,
13+
main,
14+
)
15+
16+
17+
# ---------------------------------------------------------------------------
18+
# Helpers
19+
# ---------------------------------------------------------------------------
20+
21+
22+
def _make_blob(name: str) -> MagicMock:
23+
b = MagicMock()
24+
b.name = name
25+
return b
26+
27+
28+
# ---------------------------------------------------------------------------
29+
# _confirm
30+
# ---------------------------------------------------------------------------
31+
32+
33+
@pytest.mark.parametrize("reply", ["y", "yes", "Y", "YES", "Yes"])
34+
def test_confirm_returns_true_for_affirmative(reply):
35+
with patch("builtins.input", return_value=reply):
36+
assert _confirm("Proceed?") is True
37+
38+
39+
@pytest.mark.parametrize("reply", ["n", "no", "N", "", "maybe", "nope"])
40+
def test_confirm_returns_false_for_non_affirmative(reply):
41+
with patch("builtins.input", return_value=reply):
42+
assert _confirm("Proceed?") is False
43+
44+
45+
# ---------------------------------------------------------------------------
46+
# _find_snapshots
47+
# ---------------------------------------------------------------------------
48+
49+
50+
def test_find_snapshots_excludes_status_json():
51+
blobs = [
52+
_make_blob("2026-06-01T00:00Z.json"),
53+
_make_blob("status.json"),
54+
_make_blob("2026-05-01T00:00Z.json"),
55+
]
56+
with patch("backup.cli._blob_client") as mock_blob_client:
57+
blob_service = mock_blob_client.return_value
58+
container_client = MagicMock()
59+
blob_service.get_container_client.return_value = container_client
60+
container_client.list_blobs.return_value = blobs
61+
62+
result = _find_snapshots(MagicMock(), "my-container")
63+
64+
assert "status.json" not in result
65+
assert result == ["2026-05-01T00:00Z.json", "2026-06-01T00:00Z.json"]
66+
67+
68+
def test_find_snapshots_filters_by_prefix():
69+
blobs = [
70+
_make_blob("2026-06-01T00:00Z.json"),
71+
_make_blob("2026-06-02T00:00Z.json"),
72+
_make_blob("2026-05-01T00:00Z.json"),
73+
]
74+
with patch("backup.cli._blob_client") as mock_blob_client:
75+
blob_service = mock_blob_client.return_value
76+
container_client = MagicMock()
77+
blob_service.get_container_client.return_value = container_client
78+
container_client.list_blobs.return_value = blobs
79+
80+
result = _find_snapshots(MagicMock(), "my-container", prefix="2026-06")
81+
82+
assert result == ["2026-06-01T00:00Z.json", "2026-06-02T00:00Z.json"]
83+
84+
85+
def test_find_snapshots_returns_sorted():
86+
blobs = [
87+
_make_blob("2026-06-03T00:00Z.json"),
88+
_make_blob("2026-06-01T00:00Z.json"),
89+
_make_blob("2026-06-02T00:00Z.json"),
90+
]
91+
with patch("backup.cli._blob_client") as mock_blob_client:
92+
blob_service = mock_blob_client.return_value
93+
container_client = MagicMock()
94+
blob_service.get_container_client.return_value = container_client
95+
container_client.list_blobs.return_value = blobs
96+
97+
result = _find_snapshots(MagicMock(), "my-container")
98+
99+
assert result == ["2026-06-01T00:00Z.json", "2026-06-02T00:00Z.json", "2026-06-03T00:00Z.json"]
100+
101+
102+
def test_find_snapshots_returns_empty_for_no_matches():
103+
blobs = [_make_blob("2026-06-01T00:00Z.json"), _make_blob("status.json")]
104+
with patch("backup.cli._blob_client") as mock_blob_client:
105+
blob_service = mock_blob_client.return_value
106+
container_client = MagicMock()
107+
blob_service.get_container_client.return_value = container_client
108+
container_client.list_blobs.return_value = blobs
109+
110+
result = _find_snapshots(MagicMock(), "my-container", prefix="2025-01")
111+
112+
assert result == []
113+
114+
115+
# ---------------------------------------------------------------------------
116+
# _resolve_snapshot
117+
# ---------------------------------------------------------------------------
118+
119+
120+
def test_resolve_snapshot_reads_status_json_when_no_date():
121+
status_data = {"latest_snapshot": "2026-06-11T02:00Z.json"}
122+
with (
123+
patch("backup.cli._credential"),
124+
patch("backup.cli._get_env", return_value="my-container"),
125+
patch("backup.cli._blob_client") as mock_blob_client,
126+
):
127+
blob_service = mock_blob_client.return_value
128+
status_client = MagicMock()
129+
blob_service.get_blob_client.return_value = status_client
130+
status_client.download_blob.return_value.readall.return_value = (
131+
json.dumps(status_data).encode()
132+
)
133+
134+
result = _resolve_snapshot(None)
135+
136+
assert result == "2026-06-11T02:00Z.json"
137+
138+
139+
def test_resolve_snapshot_raises_when_status_json_missing_field():
140+
with (
141+
patch("backup.cli._credential"),
142+
patch("backup.cli._get_env", return_value="my-container"),
143+
patch("backup.cli._blob_client") as mock_blob_client,
144+
):
145+
blob_service = mock_blob_client.return_value
146+
status_client = MagicMock()
147+
blob_service.get_blob_client.return_value = status_client
148+
status_client.download_blob.return_value.readall.return_value = (
149+
json.dumps({}).encode()
150+
)
151+
152+
with pytest.raises(ValueError, match="status.json has no latest_snapshot"):
153+
_resolve_snapshot(None)
154+
155+
156+
def test_resolve_snapshot_picks_latest_for_date_prefix():
157+
with (
158+
patch("backup.cli._credential"),
159+
patch("backup.cli._get_env", return_value="my-container"),
160+
patch(
161+
"backup.cli._find_snapshots",
162+
return_value=["2026-06-10T00:00Z.json", "2026-06-10T12:00Z.json"],
163+
),
164+
):
165+
result = _resolve_snapshot("2026-06-10")
166+
167+
assert result == "2026-06-10T12:00Z.json"
168+
169+
170+
def test_resolve_snapshot_raises_when_no_match_for_date():
171+
with (
172+
patch("backup.cli._credential"),
173+
patch("backup.cli._get_env", return_value="my-container"),
174+
patch("backup.cli._find_snapshots", return_value=[]),
175+
):
176+
with pytest.raises(ValueError, match="No snapshot found matching prefix"):
177+
_resolve_snapshot("2026-06-10")
178+
179+
180+
# ---------------------------------------------------------------------------
181+
# _download_snapshot
182+
# ---------------------------------------------------------------------------
183+
184+
185+
def test_download_snapshot_writes_file_and_returns_safe_path():
186+
blob_data = b'[{"PartitionKey": "a"}]'
187+
with (
188+
patch("backup.cli._credential"),
189+
patch("backup.cli._get_env", return_value="my-container"),
190+
patch("backup.cli._blob_client") as mock_blob_client,
191+
patch("builtins.open", mock_open()) as mock_file,
192+
):
193+
blob_service = mock_blob_client.return_value
194+
blob_client = MagicMock()
195+
blob_service.get_blob_client.return_value = blob_client
196+
blob_client.download_blob.return_value.readall.return_value = blob_data
197+
198+
result = _download_snapshot("2026-06-11T02:00Z.json")
199+
200+
# Colons in filenames are replaced with underscores
201+
assert result == "2026-06-11T02_00Z.json"
202+
mock_file.assert_called_once_with("2026-06-11T02_00Z.json", "wb")
203+
204+
205+
# ---------------------------------------------------------------------------
206+
# main
207+
# ---------------------------------------------------------------------------
208+
209+
210+
def test_main_backup_and_restore_skipped_on_no():
211+
with (
212+
patch("sys.argv", ["cli"]),
213+
patch("backup.cli._get_env", return_value="some_table"),
214+
patch("backup.cli._confirm", return_value=False),
215+
):
216+
result = main()
217+
218+
assert result == 0
219+
220+
221+
def test_main_returns_130_on_keyboard_interrupt():
222+
with (
223+
patch("sys.argv", ["cli"]),
224+
patch("backup.cli._get_env", return_value="some_table"),
225+
patch("backup.cli._confirm", side_effect=KeyboardInterrupt),
226+
):
227+
result = main()
228+
229+
assert result == 130
230+
231+
232+
def test_main_backup_runs_when_confirmed():
233+
with (
234+
patch("sys.argv", ["cli"]),
235+
patch("backup.cli._get_env", return_value="some_table"),
236+
patch("backup.cli._confirm", side_effect=[True, False]),
237+
patch("backup.cli.run_backup") as mock_backup,
238+
):
239+
result = main()
240+
241+
assert result == 0
242+
mock_backup.assert_called_once()
243+
244+
245+
def test_main_restore_runs_when_confirmed():
246+
with (
247+
patch("sys.argv", ["cli"]),
248+
patch("backup.cli._get_env", return_value="some_table"),
249+
patch("backup.cli._confirm", side_effect=[False, True]),
250+
patch("backup.cli._resolve_snapshot", return_value="snap.json"),
251+
patch("backup.cli._download_snapshot", return_value="local_snap.json"),
252+
patch("backup.cli.run_restore") as mock_restore,
253+
):
254+
result = main()
255+
256+
assert result == 0
257+
mock_restore.assert_called_once_with("local_snap.json", target_table=None)
258+
259+
260+
def test_main_returns_1_when_snapshot_fetch_fails():
261+
with (
262+
patch("sys.argv", ["cli"]),
263+
patch("backup.cli._get_env", return_value="some_table"),
264+
patch("backup.cli._confirm", side_effect=[False, True]),
265+
patch("backup.cli._resolve_snapshot", side_effect=ValueError("No snapshot")),
266+
):
267+
result = main()
268+
269+
assert result == 1

tests/test_core.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ class MockEntity(dict):
202202
{
203203
"AZURE_STORAGE_ACCOUNT_NAME": "myaccount",
204204
"PROD_TABLE_NAME": "mytable",
205+
"DEV_TABLE_NAME": "devtable",
205206
},
206207
)
207208
def test_run_restore_upserts_all_entities(mock_cred, mock_table_client, mock_tsc):
@@ -219,7 +220,7 @@ def test_run_restore_upserts_all_entities(mock_cred, mock_table_client, mock_tsc
219220
table.upsert_entity.assert_any_call({"PartitionKey": "a", "RowKey": "1"})
220221
table.upsert_entity.assert_any_call({"PartitionKey": "b", "RowKey": "2"})
221222
assert table.submit_transaction.call_count == 2
222-
assert len(table.submit_transaction.call_args[0][0]) == 1 # two deletes in one batch
223+
assert len(table.submit_transaction.call_args[0][0]) == 1 # one delete per PartitionKey batch
223224

224225

225226
@patch("backup.core.TableServiceClient")
@@ -244,7 +245,17 @@ def test_run_restore_creates_table_if_missing(mock_cred, mock_table_client, mock
244245
tsc_instance.create_table_if_not_exists.assert_called_once_with("mytable")
245246

246247

247-
def test_run_restore_handles_tagged_entities_with_datetime():
248+
@patch("backup.core.TableServiceClient")
249+
@patch("backup.core._credential")
250+
@patch.dict(
251+
"os.environ",
252+
{
253+
"AZURE_STORAGE_ACCOUNT_NAME": "myaccount",
254+
"PROD_TABLE_NAME": "mytable",
255+
"DEV_TABLE_NAME": "devtable",
256+
},
257+
)
258+
def test_run_restore_handles_tagged_entities_with_datetime(mock_cred, mock_tsc):
248259
"""Verify that EDM-tagged JSON (including DateTime) is deserialized correctly."""
249260
tagged = [
250261
{

0 commit comments

Comments
 (0)