"""Concurrency tests: store/users racy write scenarios. These tests hammer the same files from many threads to confirm that per-target locks, fsync, and unique tempfiles do not corrupt data. """ from __future__ import annotations from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from volumen.post import Post from volumen.store import Store from volumen.users import Users class TestStoreRace: def test_concurrent_saves_keep_all_data(self, tmp_content_dir: Path) -> None: """Many threads save distinct posts in parallel.""" store = Store(str(tmp_content_dir), default_lang="en") def worker(idx: int) -> str: slug = f"post-{idx}" post = Post(metadata={"slug": slug, "title": f"Post {idx}"}, body=f"body {idx}") store.save(post) return slug with ThreadPoolExecutor(max_workers=16) as pool: futures = [pool.submit(worker, i) for i in range(50)] for f in as_completed(futures): assert f.result() is not None posts = store.all() slugs = {p.slug for p in posts} for i in range(50): assert f"post-{i}" in slugs def test_concurrent_saves_to_same_target(self, tmp_content_dir: Path) -> None: """Many threads save to the same target path; no data lost.""" store = Store(str(tmp_content_dir), default_lang="en") def worker(idx: int) -> None: post = Post( metadata={"slug": "shared", "title": f"v{idx}"}, body=f"iteration {idx}", ) store.save(post) with ThreadPoolExecutor(max_workers=8) as pool: list(pool.map(worker, range(20))) post = store.find("shared") assert post is not None # The file content must end with one of our iterations, no torn write. assert post.body.startswith("iteration ") class TestUsersRace: def test_concurrent_adds_keep_all_users(self, users_path: str) -> None: users = Users(path=users_path) def worker(idx: int) -> str: name = f"user-{idx}" users.add(name, "alice-test-password") return name with ThreadPoolExecutor(max_workers=8) as pool: futures = [pool.submit(worker, i) for i in range(30)] for f in as_completed(futures): assert f.result() is not None assert len(users.all) == 30 class TestSnapshotCache: def test_snapshot_changes_invalidate_cache(self, tmp_content_dir: Path) -> None: store = Store(str(tmp_content_dir), default_lang="en") store.all() # prime cache (tmp_content_dir / "extra.md").write_text('+++\ntitle = "Extra"\n+++\n\nNew post.\n') # Even without touching mtime directly, stat() picks up the new file. posts = store.all() slugs = {p.slug for p in posts} assert "extra" in slugs def test_size_only_change_invalidates(self, tmp_content_dir: Path) -> None: """The cache key includes size; in-place rewrites invalidate.""" import os store = Store(str(tmp_content_dir), default_lang="en") # Force initial cache. store.all() # Overwrite an existing file with the same mtime but different size. path = tmp_content_dir / "hello.md" st = os.stat(path) os.utime(path, (st.st_atime, st.st_mtime)) path.write_text( '+++\ntitle = "Hello (resized)"\n+++\n\nNew body that is much longer than before.\n' * 5 ) # Touch back to original mtime so only size differs. os.utime(path, (st.st_atime, st.st_mtime)) post = store.find("hello") assert post is not None assert "resized" in (post.title or "")