Skip to content

Commit 18194e8

Browse files
committed
Live integration smoke + fix create_folder_recursive cache bug
Live smoke test (tests/test_live_smoke.py) found a real cache-coherency bug that all the unit-mocked tests missed: create_folder_recursive() called self.api.create_folder() directly for intermediate folders (e.g. 'A' and 'B' in '/A/B/C'). That bypasses the parent-folder cache update that self.create_folder() performs, leaving the parent cache stale. Subsequent resolve_path() walks then hit the stale root cache and raised FileNotFoundError, even though the folders definitely existed on the server. Fixed by routing all parts through self.create_folder so every parent cache stays in sync. Only the final part receives timestamp args. Live smoke design: - Auto-skips unless IXT_ACCOUNT and IXT_PWD are set in env (or .env). - All operations confined to /__pytest_internxt_cli_smoke__/<run-uuid>/ with a fresh UUID per run. - try/finally cleanup trashes the sentinel folder at module teardown, even on assertion failure. - No cassette recording — bytes/responses live only in memory; nothing about the user's account is written to the repo. - 4 tests: login+whoami, list root, full upload→list→download cycle with byte-for-byte verification, path resolution. .gitignore now excludes .env / .env.* (with .env.example escape hatch). .env was never tracked; verified before push. requirements-dev.txt: added python-dotenv (optional; tests fall back to a built-in parser if not installed). Updated test_create_folder_recursive_creates_missing_parts to match the new (correct) cache-invalidating behavior; added regression note referencing the live-smoke discovery. CHANGELOG.md and readme.md updated with the new bug entry and live- smoke documentation. Stats: 557/557 unit tests + 4/4 live tests pass; 90% coverage holds.
1 parent b4fcb41 commit 18194e8

7 files changed

Lines changed: 337 additions & 40 deletions

File tree

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,8 @@ env/
2323
# Editor
2424
.vscode/
2525
.idea/
26+
27+
# Secrets — never commit
28+
.env
29+
.env.*
30+
!.env.example

CHANGELOG.md

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,23 @@ End state: **0 ruff errors, 0 mypy errors, 0 Bandit medium+ findings,
3030
`import internxt_cli` raised `SyntaxError`.
3131
**Fixed:** added the missing triple-quote.
3232

33-
3. **`services/webdav_provider.py:set_property` — every PROPPATCH crashed**
33+
3. **`services/drive.py:create_folder_recursive` — stale parent cache after intermediate folder creation** *(found by live integration test)*
34+
When creating intermediate folders for a nested path like
35+
`/A/B/C`, the function called `self.api.create_folder` directly,
36+
which does **not** invalidate the parent folder's content cache. The
37+
final folder went through `self.create_folder` (which does update the
38+
cache), but by then the parent caches for `A` and `B` were stale.
39+
Subsequent `resolve_path()` calls would walk the tree using the stale
40+
root cache and fail with `FileNotFoundError`, even though the folder
41+
chain definitely existed on the server. This was masked in unit
42+
tests by stubbing `get_folder_content` directly. **Caught only when
43+
the live smoke test (`tests/test_live_smoke.py`) ran the create →
44+
resolve cycle against the real backend.**
45+
**Fixed:** route all parts (intermediate + final) through
46+
`self.create_folder` so every parent cache stays in sync; only the
47+
final part receives timestamp arguments.
48+
49+
4. **`services/webdav_provider.py:set_property` — every PROPPATCH crashed**
3450
This is the handler macOS Finder and Windows Explorer use to set
3551
creation/modification times on remote folders. Two broken pieces:
3652
- It imported `rfc_1123_to_timestamp` and `rfc_3339_to_timestamp` from
@@ -253,10 +269,42 @@ The trust roots (auth + crypto) are at 100%. The remaining gaps in
253269
diagnostic branches that are best exercised by integration tests against
254270
a live Internxt backend, not more unit mocks.
255271

256-
#### Not yet covered (intentional)
257-
258-
- A vcrpy-recorded contract test against the real Internxt API for one
259-
full upload→download cycle. Needs test credentials and a one-time
260-
recording session; would lock in the exact wire-format expectations of
261-
the production backend so future API changes fail the test in CI
262-
rather than in the field.
272+
#### Live integration smoke test
273+
274+
`tests/test_live_smoke.py` — opt-in test that runs against the real
275+
Internxt backend. Auto-skipped unless `IXT_ACCOUNT` and `IXT_PWD` are
276+
set in env (or in a gitignored `.env` file). Safety properties:
277+
278+
- All operations happen under a **sentinel folder**
279+
`/__pytest_internxt_cli_smoke__/<run-uuid>/` with a fresh UUID per
280+
run. Nothing outside that prefix is touched.
281+
- A try/finally cleanup trashes the entire sentinel folder at module
282+
teardown, even on test failure.
283+
- **No cassette recording** — bytes and responses live only in memory;
284+
nothing about the user's account is written to the repo.
285+
286+
Tests covered:
287+
1. Login + whoami (read-only)
288+
2. List root folder (read-only)
289+
3. End-to-end cycle: create folder → upload → list → download → assert
290+
byte-for-byte recovery
291+
4. Path resolution against the live folder tree
292+
293+
To run:
294+
295+
```bash
296+
# Once: put creds in .env (gitignored)
297+
echo 'IXT_ACCOUNT=you@example.com' > .env
298+
echo 'IXT_PWD=your-password' >> .env
299+
300+
# Run the live smoke
301+
pytest tests/test_live_smoke.py -v -s
302+
303+
# Force-skip (e.g., in CI)
304+
PYTEST_SKIP_LIVE=1 pytest
305+
```
306+
307+
This test surfaced the `create_folder_recursive` cache-coherency bug
308+
listed above (item 3 in the Critical bug-fix section), which all the
309+
unit-mocked tests missed because they stubbed `get_folder_content`
310+
directly.

readme.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,24 @@ Per-module coverage:
330330
See [`CHANGELOG.md`](CHANGELOG.md) for the full audit summary and the
331331
list of bugs found and fixed during the test build-out.
332332

333+
#### Live integration smoke (optional)
334+
335+
`tests/test_live_smoke.py` runs an end-to-end cycle (login → create
336+
folder → upload → list → download → verify bytes → trash) against the
337+
real Internxt backend. Auto-skipped unless credentials are present.
338+
339+
```bash
340+
# Put creds in a .env file (gitignored — never committed)
341+
echo 'IXT_ACCOUNT=you@example.com' > .env
342+
echo 'IXT_PWD=your-password' >> .env
343+
344+
pytest tests/test_live_smoke.py -v -s
345+
```
346+
347+
All operations happen inside a unique sentinel folder
348+
(`/__pytest_internxt_cli_smoke__/<run-uuid>/`) which is always trashed
349+
at teardown — your real files are never touched.
350+
333351
### Quality Gates
334352

335353
The CI runs four gates on every push/PR:

requirements-dev.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,7 @@ pytest-cov>=4.1.0
55
ruff>=0.1.0
66
mypy>=1.5.0
77
bandit>=1.7.0
8+
9+
# Optional: load IXT_ACCOUNT/IXT_PWD from .env for the live smoke test
10+
# (tests fall back to a built-in parser if not installed)
11+
python-dotenv>=1.0.0

services/drive.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,22 +1278,20 @@ def create_folder_recursive(self, path: str,
12781278
try:
12791279
print(f" -> Creating new folder: {part} in {current_path_so_far}")
12801280

1281-
# We only pass timestamps if this is the *final* folder
1281+
# Use self.create_folder for both intermediate and final
1282+
# parts so the parent cache is invalidated/updated. Only
1283+
# the final part gets timestamps applied.
12821284
if is_last_part:
12831285
print(f" -> 🕐 Applying timestamps to new folder: {part}")
12841286
new_folder = self.create_folder(
1285-
part,
1287+
part,
12861288
current_parent_uuid,
12871289
creation_time=creation_time,
1288-
modification_time=modification_time
1290+
modification_time=modification_time,
12891291
)
12901292
else:
1291-
# Create intermediate folders *without* timestamps
1292-
new_folder = self.api.create_folder({
1293-
'plainName': part,
1294-
'parentFolderUuid': current_parent_uuid
1295-
})
1296-
1293+
new_folder = self.create_folder(part, current_parent_uuid)
1294+
12971295
current_parent_uuid = new_folder['uuid']
12981296
final_folder_info = new_folder
12991297

tests/test_drive_path_resolution.py

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -175,16 +175,18 @@ def test_create_folder_recursive_existing_no_api_call():
175175

176176

177177
def test_create_folder_recursive_creates_missing_parts():
178-
"""Intermediate folders go through api.create_folder; final folder uses
179-
drive_service.create_folder so timestamps can be applied."""
180-
# Initially empty tree at every level.
178+
"""All parts (intermediate + final) go through drive_service.create_folder
179+
so the parent cache is updated for each. Only the final part gets
180+
timestamps applied.
181+
182+
Regression: prior implementation called api.create_folder directly for
183+
intermediate parts, which bypassed the parent-cache update and made
184+
subsequent resolve_path() calls fail with FileNotFoundError when reading
185+
from a stale root cache. Caught by tests/test_live_smoke.py.
186+
"""
181187
tree = {'root-uuid': {'folders': [], 'files': []}}
182188

183-
def fake_create_intermediate(payload):
184-
# Simulate the server returning a uuid for the new intermediate folder
185-
return {'uuid': f"new-{payload['plainName']}", 'plainName': payload['plainName']}
186-
187-
def fake_create_final(name, parent_uuid, creation_time=None, modification_time=None):
189+
def fake_create(name, parent_uuid, creation_time=None, modification_time=None):
188190
return {
189191
'uuid': f"new-{name}", 'plainName': name,
190192
'parentFolderUuid': parent_uuid,
@@ -193,28 +195,31 @@ def fake_create_final(name, parent_uuid, creation_time=None, modification_time=N
193195

194196
with _set_root(), \
195197
_stub_get_folder_content(tree), \
196-
patch.object(drive_service.api, 'create_folder',
197-
side_effect=fake_create_intermediate) as mock_api_create, \
198+
patch.object(drive_service.api, 'create_folder') as mock_api_create, \
198199
patch.object(drive_service, 'create_folder',
199-
side_effect=fake_create_final) as mock_create_final_top:
200+
side_effect=fake_create) as mock_create:
200201
out = drive_service.create_folder_recursive(
201202
'/A/B/C',
202203
creation_time='2026-01-01T00:00:00Z',
203204
modification_time='2026-01-02T00:00:00Z',
204205
)
205206

206-
# /A and /A/B are intermediate -> 2 api.create_folder calls
207-
assert mock_api_create.call_count == 2
208-
intermediate_names = [call.args[0]['plainName'] for call in mock_api_create.call_args_list]
209-
assert intermediate_names == ['A', 'B']
210-
211-
# /A/B/C is the final part -> 1 drive_service.create_folder call
212-
# with timestamps preserved.
213-
mock_create_final_top.assert_called_once()
214-
args, kwargs = mock_create_final_top.call_args
215-
assert args[0] == 'C'
216-
assert kwargs.get('creation_time') == '2026-01-01T00:00:00Z'
217-
assert kwargs.get('modification_time') == '2026-01-02T00:00:00Z'
207+
# All 3 parts go through self.create_folder (which keeps the parent
208+
# cache in sync). The raw api.create_folder is NOT called directly.
209+
mock_api_create.assert_not_called()
210+
assert mock_create.call_count == 3
211+
names_in_order = [call.args[0] for call in mock_create.call_args_list]
212+
assert names_in_order == ['A', 'B', 'C']
213+
214+
# Only the LAST call (for 'C') gets timestamps; intermediates pass None.
215+
intermediate_call = mock_create.call_args_list[0]
216+
assert intermediate_call.kwargs.get('creation_time') is None
217+
assert intermediate_call.kwargs.get('modification_time') is None
218+
219+
final_call = mock_create.call_args_list[-1]
220+
assert final_call.args[0] == 'C'
221+
assert final_call.kwargs.get('creation_time') == '2026-01-01T00:00:00Z'
222+
assert final_call.kwargs.get('modification_time') == '2026-01-02T00:00:00Z'
218223

219224
# Returned info points at the final folder
220225
assert out['uuid'] == 'new-C'

0 commit comments

Comments
 (0)