"""Tests for shopstack.services.community_federation (Phase 11)."""
from __future__ import annotations
import json
from datetime import date, datetime
from pathlib import Path
import pytest
from shopstack.services.community_federation import (
BUNDLE_HEADER_KEY,
BUNDLE_VERSION,
CommunityBundle,
export_bundle,
export_bundle_to_string,
import_bundle,
sync_status,
)
from shopstack.services.community_price_map import (
clear_pool,
is_opted_in,
set_opt_in,
)
# ── Bundle serialization round-trip ─────────────────────────
def test_bundle_to_jsonl_includes_header():
bundle = CommunityBundle(
version=1, salt_fingerprint="abc123", source_label="test",
exported_at="2026-06-13T10:00:00",
day_range_start="2026-06-01", day_range_end="2026-06-13",
observations=[{"canonical_name": "milk", "price": 60.0,
"city": "mumbai", "store": "DMart", "day": "2026-06-13",
"anon_id": "anon-1", "unit": "L"}],
)
raw = bundle.to_jsonl()
lines = raw.strip().split("\n")
assert len(lines) == 2
header_obj = json.loads(lines[0])
assert BUNDLE_HEADER_KEY in header_obj
assert header_obj[BUNDLE_HEADER_KEY]["version"] == 1
assert header_obj[BUNDLE_HEADER_KEY]["observation_count"] == 1
obs = json.loads(lines[1])
assert obs["canonical_name"] == "milk"
def test_bundle_round_trip():
bundle = CommunityBundle(
version=1, salt_fingerprint="xyz", source_label="round-trip",
exported_at="2026-06-13T10:00:00",
day_range_start="2026-06-01", day_range_end="2026-06-13",
observations=[
{"canonical_name": "milk", "price": 60.0, "city": "mumbai",
"store": "DMart", "day": "2026-06-13", "anon_id": "a1", "unit": "L"},
{"canonical_name": "bread", "price": 40.0, "city": "delhi",
"store": "Reliance", "day": "2026-06-12", "anon_id": "a2", "unit": "pack"},
],
)
raw = bundle.to_jsonl()
parsed = CommunityBundle.from_jsonl(raw)
assert parsed.version == 1
assert parsed.source_label == "round-trip"
assert len(parsed.observations) == 2
assert parsed.observations[0]["canonical_name"] == "milk"
def test_bundle_from_jsonl_rejects_unsupported_version():
raw = json.dumps({BUNDLE_HEADER_KEY: {"version": 99}}) + "\n"
with pytest.raises(ValueError, match="Unsupported bundle version"):
CommunityBundle.from_jsonl(raw)
def test_bundle_from_jsonl_rejects_missing_header():
raw = json.dumps({"canonical_name": "milk", "price": 60.0})
with pytest.raises(ValueError, match="missing the header line"):
CommunityBundle.from_jsonl(raw)
def test_bundle_from_jsonl_rejects_invalid_json():
raw = "not json\n"
with pytest.raises(ValueError, match="Invalid JSON"):
CommunityBundle.from_jsonl(raw)
def test_bundle_handles_blank_lines():
raw = json.dumps({BUNDLE_HEADER_KEY: {"version": 1, "salt_fingerprint": "x"}}) + "\n\n\n"
parsed = CommunityBundle.from_jsonl(raw)
assert parsed.observations == []
# ── Export ──────────────────────────────────────────────
def test_export_bundle_empty_pool():
bundle = export_bundle()
assert bundle.version == 1
assert bundle.observations == []
assert bundle.day_range_start == ""
assert bundle.day_range_end == ""
def test_export_bundle_filters_by_city(tmp_path, monkeypatch):
from shopstack.services import community_price_map as cpm
fake_pool = tmp_path / "pool.jsonl"
monkeypatch.setattr(cpm, "_POOL_FILE", fake_pool)
monkeypatch.setattr(cpm, "_OPTED_IN_FILE", tmp_path / "opted_in.json")
set_opt_in("hh-1", True)
cpm.submit_observation("hh-1", "milk", 60.0, city="mumbai", store="DMart")
cpm.submit_observation("hh-1", "bread", 40.0, city="delhi", store="Reliance")
bundle = export_bundle(city="mumbai")
assert len(bundle.observations) == 1
assert bundle.observations[0]["canonical_name"] == "milk"
def test_export_bundle_to_string_round_trip(tmp_path, monkeypatch):
from shopstack.services import community_price_map as cpm
monkeypatch.setattr(cpm, "_POOL_FILE", tmp_path / "pool.jsonl")
monkeypatch.setattr(cpm, "_OPTED_IN_FILE", tmp_path / "opted_in.json")
set_opt_in("hh-1", True)
cpm.submit_observation("hh-1", "milk", 60.0, city="mumbai", store="DMart")
raw = export_bundle_to_string()
parsed = CommunityBundle.from_jsonl(raw)
assert len(parsed.observations) == 1
# ── Import ──────────────────────────────────────────────
def test_import_bundle_refuses_when_not_opted_in(tmp_path, monkeypatch):
from shopstack.services import community_price_map as cpm
monkeypatch.setattr(cpm, "_POOL_FILE", tmp_path / "pool.jsonl")
monkeypatch.setattr(cpm, "_OPTED_IN_FILE", tmp_path / "opted_in.json")
# Don't opt in
raw = export_bundle_to_string()
result = import_bundle(raw, actor_user_id="hh-1")
assert result["accepted"] == 0
assert "not opted in" in result["reason"].lower()
def test_import_bundle_appends_to_pool(tmp_path, monkeypatch):
from shopstack.services import community_price_map as cpm
monkeypatch.setattr(cpm, "_POOL_FILE", tmp_path / "pool.jsonl")
monkeypatch.setattr(cpm, "_OPTED_IN_FILE", tmp_path / "opted_in.json")
set_opt_in("hh-1", True)
# Build a bundle manually
bundle = CommunityBundle(
version=1, salt_fingerprint="src-salt", source_label="friend",
exported_at="2026-06-13T10:00:00",
day_range_start="2026-06-12", day_range_end="2026-06-13",
observations=[
{"canonical_name": "milk", "price": 60.0, "city": "mumbai",
"store": "DMart", "day": "2026-06-13", "anon_id": "a1", "unit": "L"},
],
)
raw = bundle.to_jsonl()
result = import_bundle(raw, actor_user_id="hh-1")
assert result["accepted"] == 1
assert result["rejected"] == 0
# The pool should now have 1 observation
stats = cpm.pool_stats()
assert stats["size"] == 1
def test_import_bundle_dedupes_observations(tmp_path, monkeypatch):
from shopstack.services import community_price_map as cpm
monkeypatch.setattr(cpm, "_POOL_FILE", tmp_path / "pool.jsonl")
monkeypatch.setattr(cpm, "_OPTED_IN_FILE", tmp_path / "opted_in.json")
set_opt_in("hh-1", True)
bundle = CommunityBundle(
version=1, salt_fingerprint="x", source_label="dup-test",
exported_at="2026-06-13T10:00:00",
day_range_start="2026-06-13", day_range_end="2026-06-13",
observations=[
{"canonical_name": "milk", "price": 60.0, "city": "mumbai",
"store": "DMart", "day": "2026-06-13", "anon_id": "a1", "unit": "L"},
],
)
raw = bundle.to_jsonl()
# First import: 1 accepted
r1 = import_bundle(raw, actor_user_id="hh-1")
assert r1["accepted"] == 1
# Second import: dedup → 0 accepted, 1 skipped
r2 = import_bundle(raw, actor_user_id="hh-1")
assert r2["accepted"] == 0
assert r2["skipped"] == 1
def test_import_bundle_rejects_invalid_observations(tmp_path, monkeypatch):
from shopstack.services import community_price_map as cpm
monkeypatch.setattr(cpm, "_POOL_FILE", tmp_path / "pool.jsonl")
monkeypatch.setattr(cpm, "_OPTED_IN_FILE", tmp_path / "opted_in.json")
set_opt_in("hh-1", True)
bundle = CommunityBundle(
version=1, salt_fingerprint="x", source_label="invalid-test",
exported_at="2026-06-13T10:00:00",
day_range_start="2026-06-13", day_range_end="2026-06-13",
observations=[
{"canonical_name": "", "price": 60.0, "city": "mumbai",
"store": "DMart", "day": "2026-06-13", "anon_id": "a1", "unit": "L"},
{"canonical_name": "milk", "price": 0, "city": "mumbai",
"store": "DMart", "day": "2026-06-13", "anon_id": "a2", "unit": "L"},
{"canonical_name": "milk", "price": 50.0, "city": "mumbai",
"store": "DMart", "day": "2026-06-13", "anon_id": "a3", "unit": "L"},
],
)
raw = bundle.to_jsonl()
result = import_bundle(raw, actor_user_id="hh-1")
assert result["rejected"] == 2
assert result["accepted"] == 1
def test_import_bundle_handles_invalid_jsonl(tmp_path, monkeypatch):
from shopstack.services import community_price_map as cpm
monkeypatch.setattr(cpm, "_POOL_FILE", tmp_path / "pool.jsonl")
monkeypatch.setattr(cpm, "_OPTED_IN_FILE", tmp_path / "opted_in.json")
set_opt_in("hh-1", True)
result = import_bundle("not json", actor_user_id="hh-1")
assert result["accepted"] == 0
assert "Invalid bundle" in result["reason"]
# ── sync_status ────────────────────────────────────────
def test_sync_status_not_opted_in(tmp_path, monkeypatch):
from shopstack.services import community_price_map as cpm
monkeypatch.setattr(cpm, "_POOL_FILE", tmp_path / "pool.jsonl")
monkeypatch.setattr(cpm, "_OPTED_IN_FILE", tmp_path / "opted_in.json")
html = sync_status("hh-1")
assert "Opt in" in html or "🔒" in html
def test_sync_status_opted_in_with_data(tmp_path, monkeypatch):
from shopstack.services import community_price_map as cpm
monkeypatch.setattr(cpm, "_POOL_FILE", tmp_path / "pool.jsonl")
monkeypatch.setattr(cpm, "_OPTED_IN_FILE", tmp_path / "opted_in.json")
set_opt_in("hh-1", True)
cpm.submit_observation("hh-1", "milk", 60.0, city="mumbai", store="DMart")
html = sync_status("hh-1")
assert "📡" in html
assert "1" in html
assert "milk" in html.lower() or "items" in html.lower()
def test_sync_status_opted_in_empty_pool(tmp_path, monkeypatch):
from shopstack.services import community_price_map as cpm
monkeypatch.setattr(cpm, "_POOL_FILE", tmp_path / "pool.jsonl")
monkeypatch.setattr(cpm, "_OPTED_IN_FILE", tmp_path / "opted_in.json")
set_opt_in("hh-1", True)
html = sync_status("hh-1")
assert "empty" in html.lower() or "Opted" in html
# ── XSS safety ────────────────────────────────────────
def test_bundle_to_jsonl_escapes_pii_fields():
bundle = CommunityBundle(
version=1, salt_fingerprint="x", source_label="",
exported_at="2026-06-13T10:00:00",
day_range_start="2026-06-13", day_range_end="2026-06-13",
observations=[],
)
raw = bundle.to_jsonl()
# The source_label is JSON-escaped (within quotes), so the
# raw "
def test_sync_status_escapes_xss():
html = sync_status("")
# The actor_id in the URL is escaped
assert "" not in html