- Timestamp:
- 10/23/25 13:27:08 (3 months ago)
- Branches:
- master
- Children:
- 4ceca57
- Parents:
- 6a7dec1
- Location:
- tests
- Files:
-
- 4 edited
-
conftest.py (modified) (1 diff)
-
test_id_lifecycle.py (modified) (1 diff)
-
test_id_stress.py (modified) (2 diffs)
-
test_persistance_integrity.py (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
tests/conftest.py
r6a7dec1 r02d288d 5 5 from flexoentity import FlexoEntity, EntityType, EntityState, Domain 6 6 7 import pytest 8 import json 9 from flexoentity import EntityType, EntityState, Domain 10 from builder.questions import RadioQuestion, AnswerOption # adjust path if different 11 from builder.media_items import NullMediaItem # adjust import if needed 7 12 8 class DummyEntity(FlexoEntity):9 """Minimal concrete subclass for testing FlexoEntity logic."""10 13 11 def __init__(self, domain, etype, state, seed="DUMMY"): 12 self._seed = seed 13 super().__init__(domain, etype, state) 14 @pytest.fixture(scope="session") 15 def domain(): 16 """Provide a reusable domain for all entity tests.""" 17 return Domain( 18 domain="SIG", 19 etype=EntityType.DOMAIN, 20 state=EntityState.DRAFT, 21 fullname="Signal Corps", 22 description="Questions related to communications and signaling systems.", 23 classification="RESTRICTED", 24 owner="test-suite" 25 ) 14 26 15 @property16 def text_seed(self) -> str:17 return self._seed18 27 19 @classmethod 20 def from_dict(cls, data): 21 """Ensure enums and seed are reconstructed correctly.""" 22 domain = data["domain"] 23 etype = EntityType[data["etype"]] if isinstance(data["etype"], str) else data["etype"] 24 state = EntityState[data["state"]] if isinstance(data["state"], str) else data["state"] 25 seed = data.get("text_seed", "DUMMY-CONTENT") 26 return cls(domain=domain, etype=etype, state=state, seed=seed) 28 @pytest.fixture 29 def radio_question(domain): 30 """Return a simple RadioQuestion entity for testing FlexoEntity logic.""" 31 q = RadioQuestion( 32 domain=domain, 33 etype=EntityType.QUESTION, 34 state=EntityState.DRAFT, 35 text="Which frequency band is used for shortwave communication?", 36 options=[ 37 AnswerOption(id="opt1", text="HF (3–30 MHz)", points=1), 38 AnswerOption(id="opt2", text="VHF (30–300 MHz)", points=0), 39 AnswerOption(id="opt3", text="UHF (300–3000 MHz)", points=0), 40 ] 41 ) 42 return q 27 43 28 @classmethod 29 def from_json(cls, data_str: str): 30 return cls.from_dict(json.loads(data_str)) 31 44 32 45 @pytest.fixture 33 def entity(): 34 """Generic FlexoEntity-like instance in draft state.""" 35 return DummyEntity( 36 domain=Domain(domain="SIG", etype=EntityType.DOMAIN, state=EntityState.DRAFT, fullname="Signal Corps", classification="RESTRICTED"), 37 etype=EntityType.CATALOG, 38 state=EntityState.DRAFT, 39 ) 46 def serialized_question(radio_question): 47 """Provide the serialized JSON form for roundtrip tests.""" 48 return radio_question.to_json() 49 50 51 @pytest.fixture 52 def deserialized_question(serialized_question): 53 """Recreate a question from JSON for consistency tests.""" 54 return RadioQuestion.from_json(serialized_question) 55 40 56 41 57 @pytest.fixture 42 58 def null_media(): 43 """Provide a default NullMediaItem instance for tests."""59 """Provide a default NullMediaItem instance for media tests.""" 44 60 return NullMediaItem( 45 domain= "GEN",61 domain=domain, 46 62 etype=EntityType.MEDIA, 47 63 state=EntityState.DRAFT -
tests/test_id_lifecycle.py
r6a7dec1 r02d288d 1 1 import pytest 2 3 from flexoentity import FlexOID, FlexoEntity, EntityType, EntityState 4 5 def test_initial_state(entity): 6 assert entity.state == EntityState.DRAFT 7 assert entity.flexo_id.version == 1 8 assert len(entity.flexo_id.signature) == 16 # blake2s digest_size=8 → 16 hex 9 assert FlexoEntity.verify_integrity(entity) 2 from flexoentity import FlexOID, FlexoEntity, EntityType, EntityState 10 3 11 4 12 def test_approval_bumps_version(entity): 13 entity.approve() 14 assert entity.state == EntityState.APPROVED 15 assert entity.flexo_id.version == 2 5 # ────────────────────────────────────────────────────────────────────────────── 6 # Tests adapted to use real RadioQuestion fixture instead of DummyEntity 7 # ────────────────────────────────────────────────────────────────────────────── 8 9 def test_initial_state(radio_question): 10 q = radio_question 11 assert q.state == EntityState.DRAFT 12 assert q.flexo_id.version == 1 13 assert FlexoEntity.verify_integrity(q) 16 14 17 15 18 def test_signing_bumps_version(entity): 19 entity.approve() 20 v_before = entity.flexo_id 21 entity.sign() 22 assert entity.state == EntityState.APPROVED_AND_SIGNED 23 assert entity.flexo_id != v_before 16 def test_approval_bumps_version(radio_question): 17 q = radio_question 18 q.approve() 19 assert q.state == EntityState.APPROVED 20 assert q.flexo_id.version == 2 24 21 25 22 26 def test_ publish_bumps_version(entity):27 entity.approve()28 entity.sign()29 v_before = entity.flexo_id.version30 entity.publish()31 assert entity.state == EntityState.PUBLISHED32 assert entity.flexo_id.version == v_before + 123 def test_signing_bumps_version(radio_question): 24 q = radio_question 25 q.approve() 26 v_before = str(q.flexo_id) 27 q.sign() 28 assert q.state == EntityState.APPROVED_AND_SIGNED 29 assert str(q.flexo_id) != v_before 33 30 34 31 35 def test_modify_content_changes_fingerprint(entity): 36 old_signature = entity.flexo_id.signature 37 entity._seed = "Rephrased content" # simulate text change 38 entity._update_fingerprint() 39 assert entity.flexo_id.signature != old_signature 32 def test_publish_bumps_version(radio_question): 33 q = radio_question 34 q.approve() 35 q.sign() 36 v_before = q.flexo_id.version 37 q.publish() 38 assert q.state == EntityState.PUBLISHED 39 assert q.flexo_id.version == v_before + 1 40 40 41 41 42 def test_no_version_bump_on_draft_edits(entity): 43 entity._seed = "Draft edit only" 44 entity._update_fingerprint() 45 assert entity.flexo_id.version == 1 42 def test_modify_content_changes_fingerprint(radio_question): 43 q = radio_question 44 q.text = "Rephrased content" # simulate text change 45 changed = q._update_fingerprint() 46 assert changed 46 47 47 48 48 def test_version_bump_after_edit_and_sign(entity): 49 entity.approve() 50 v1 = entity.flexo_id 51 entity._seed = "Changed content" 52 entity.sign() 53 assert entity.flexo_id != v1 49 def test_no_version_bump_on_draft_edits(radio_question): 50 q = radio_question 51 q.text = "Minor draft edit" 52 q._update_fingerprint() 53 assert q.flexo_id.version == 1 54 54 55 55 56 def test_integrity_check_passes_and_fails(entity): 57 entity.approve() 58 assert FlexoEntity.verify_integrity(entity) 59 # simulate tampering 60 entity._seed = "Tampered text" 61 assert not FlexoEntity.verify_integrity(entity) 56 def test_version_bump_after_edit_and_sign(radio_question): 57 q = radio_question 58 q.approve() 59 v1 = str(q.flexo_id) 60 q.text = "Changed content" 61 q.sign() 62 assert str(q.flexo_id) != v1 62 63 63 64 64 def test_obsolete_state(entity): 65 entity.approve() 66 entity.sign() 67 entity.publish() 68 entity.obsolete() 69 assert entity.state == EntityState.OBSOLETE 65 def test_integrity_check_passes_and_fails(radio_question): 66 q = radio_question 67 q.approve() 68 assert FlexoEntity.verify_integrity(q) 69 70 # simulate tampering 71 q.text = "Tampered text" 72 assert not FlexoEntity.verify_integrity(q) 70 73 71 74 72 def test_clone_new_base_resets_lineage(entity): 73 entity.approve() 74 entity.sign() 75 entity.publish() 76 entity.obsolete() 77 old_id = entity.flexo_id 78 entity.clone_new_base() 79 assert entity.flexo_id != old_id 80 assert entity.state == EntityState.DRAFT 81 assert entity.flexo_id.version == 1 75 def test_obsolete_state(radio_question): 76 q = radio_question 77 q.approve() 78 q.sign() 79 q.publish() 80 q.obsolete() 81 assert q.state == EntityState.OBSOLETE 82 82 83 83 84 def test_mass_version_increments_until_obsolete(entity): 85 entity.approve() 84 def test_clone_new_base_resets_lineage(radio_question): 85 q = radio_question 86 q.approve() 87 q.sign() 88 q.publish() 89 q.obsolete() 90 old_id = str(q.flexo_id) 91 q.clone_new_base() 92 assert str(q.flexo_id) != old_id 93 assert q.state == EntityState.DRAFT 94 assert q.flexo_id.version == 1 95 96 97 def test_mass_version_increments_until_obsolete(radio_question): 98 q = radio_question 99 q.approve() 86 100 for _ in range(FlexOID.MAX_VERSION - 2): 87 entity.sign()101 q.sign() 88 102 with pytest.raises(RuntimeError, match="mark obsolete"): 89 entity.sign()103 q.sign() -
tests/test_id_stress.py
r6a7dec1 r02d288d 3 3 Focus: collision avoidance, version ceiling, reproducibility. 4 4 """ 5 5 6 import pytest 6 7 import random 8 import logging 9 from flexoentity import FlexOID, EntityType, EntityState 10 from builder.questions import RadioQuestion, AnswerOption 7 11 8 from flexoentity import FlexOID, EntityType, EntityState, Domain 12 logger = logging.getLogger(__name__) 9 13 10 from tests.conftest import DummyEntity 11 12 # ────────────────────────────────────────────────────────────────────────────── 13 def test_bulk_generation_uniqueness(): 14 """Generate 10,000 IDs and assert uniqueness (statistical test).""" 15 domain = Domain(domain="SIG", etype=EntityType.DOMAIN, state=EntityState.DRAFT, 16 fullname="Signal Corps", classification="RESTRICTED", owner="MESE") 17 14 def test_bulk_generation_uniqueness(domain): 15 """ 16 Generate 10,000 IDs and ensure uniqueness using safe_generate(). 17 If a collision occurs, safe_generate() must resolve it automatically 18 via salt + date adjustment. 19 """ 18 20 etype = EntityType.QUESTION 19 21 estate = EntityState.DRAFT 20 seeds = [f"question {i}" for i in range( 10_000)]22 seeds = [f"question {i}" for i in range(4000000)] 21 23 22 ids = [FlexOID.generate(domain, etype, estate, seed) for seed in seeds] 24 # Simulate a simple in-memory repository for collision detection 25 repo = {} 23 26 24 assert len(ids) == len(set(ids)), "ID collisions detected in bulk generation" 27 def repo_get(oid_str): 28 return repo.get(str(oid_str)) 25 29 30 # Generate IDs using safe_generate 31 ids = [] 32 for seed in seeds: 33 oid = FlexOID.safe_generate(domain.domain, etype, estate, seed, repo=repo) 34 assert isinstance(oid, FlexOID) 35 ids.append(str(oid)) 36 repo[str(oid)] = oid # register for future collision detection 26 37 27 def test_disambiguator_trigger(): 38 unique_count = len(set(ids)) 39 total_count = len(ids) 40 collisions = total_count - unique_count 41 42 logger.info(f"Generated {total_count} IDs ({collisions} collisions handled).") 43 44 # Assert that safe_generate avoided duplicates 45 assert total_count == unique_count, f"Unexpected duplicate IDs ({collisions} found)" 46 47 # Sanity check: IDs should look canonical 48 assert all(id_str.startswith("SIG-") for id_str in ids) 49 assert all("@" in id_str for id_str in ids) 50 51 def test_id_generation_is_deterministic(domain): 28 52 """ 29 53 Generating the same entity twice with same inputs yields identical ID. 30 54 (No runtime disambiguation; IDs are deterministic by design.) 31 55 """ 32 domain = "AF"33 56 etype = EntityType.QUESTION 34 57 estate = EntityState.DRAFT 35 58 text = "identical question text" 36 id1 = FlexOID.generate(domain, etype, estate, text) 37 id2 = FlexOID.generate(domain, etype, estate, text) 38 # IDs must be identical, because we now enforce determinism, not randomization 59 60 id1 = FlexOID.generate(domain.domain, etype, estate, text) 61 id2 = FlexOID.generate(domain.domain, etype, estate, text) 62 # IDs must be identical because generation is deterministic 39 63 assert id1 == id2 40 assert id1.signature == id2.signature41 64 42 65 43 def test_id_reproducibility_across_runs( ):66 def test_id_reproducibility_across_runs(domain): 44 67 """ 45 68 The same seed on a new process (fresh _seen_hashes) 46 69 should yield the same base ID (without suffix). 47 70 """ 48 domain = Domain(domain="SIG", etype=EntityType.DOMAIN, state=EntityState.DRAFT,49 fullname="Signal Corps", classification="RESTRICTED")50 71 etype = EntityType.CATALOG 51 72 estate = EntityState.DRAFT 52 73 seed = "reproducibility test seed" 53 id1 = FlexOID.generate(domain, etype, estate, seed) 54 # Reset hash cache74 75 id1 = FlexOID.generate(domain.domain, etype, estate, seed) 55 76 FlexOID._seen_hashes.clear() 56 id2 = FlexOID.generate(domain, etype, estate, seed) 77 id2 = FlexOID.generate(domain.domain, etype, estate, seed) 78 57 79 assert id1 == id2 58 assert id1.signature == id2.signature59 80 60 81 61 def test_version_ceiling_enforcement( ):82 def test_version_ceiling_enforcement(radio_question): 62 83 """Simulate approaching @999 to trigger obsolescence guard.""" 63 entity = DummyEntity(domain="AF", etype=EntityType.EXAM, state=EntityState.DRAFT, seed="Final Exam 2025") 64 entity.approve() 84 q = radio_question 85 q.approve() 86 65 87 # artificially bump version number to near ceiling 66 entity.flexo_id = FlexOID.from_oid_and_version(entity.flexo_id, 998)88 q.flexo_id = FlexOID.from_oid_and_version(q.flexo_id, 998) 67 89 68 90 # 998 → 999 is allowed 69 entity.sign()70 assert entity.flexo_id.version == 99991 q.sign() 92 assert q.flexo_id.version == 999 71 93 72 94 # 999 → 1000 should raise RuntimeError 73 95 with pytest.raises(RuntimeError): 74 entity.sign()96 q.sign() 75 97 76 98 77 def test_massive_lifecycle_simulation( ):99 def test_massive_lifecycle_simulation(domain): 78 100 """ 79 Generate 100 random entities, simulate multiple edits and state transitions,101 Generate 100 random RadioQuestions, simulate multiple edits and state transitions, 80 102 ensure all final IDs and fingerprints are unique and valid. 81 103 """ 82 entities = [DummyEntity(domain="AF", etype=EntityType.QUESTION, state=EntityState.DRAFT, seed=f"random question {i}") for i in range(100)] 104 entities = [ 105 RadioQuestion( 106 domain=domain, 107 etype=EntityType.QUESTION, 108 state=EntityState.DRAFT, 109 text=f"random question {i}", 110 options=[ 111 AnswerOption(id="opt4", text="HF (3–30 MHz)", points=1), 112 AnswerOption(id="opt5", text="VHF (30–300 MHz)", points=0), 113 ], 114 ) 115 for i in range(100) 116 ] 83 117 84 118 for e in entities: 85 # random edit , approval, signing86 e. _seed+= " updated"119 # random edit 120 e.text += " updated" 87 121 e._update_fingerprint() 122 123 # lifecycle transitions 88 124 e.approve() 89 125 if random.random() > 0.3: … … 92 128 e.publish() 93 129 94 ids = [e.flexo_id for e in entities] 95 fps = [e.flexo_id.signature for e in entities] 96 assert len(ids) == len(set(ids)), "Duplicate IDs after random lifecycle" 97 assert len(fps) == len(set(fps)), "Duplicate fingerprints after random lifecycle" 130 flexoids = [e.flexo_id for e in entities] 131 assert len(flexoids) == len(set(flexoids)), "Duplicate FlexOIDs after lifecycle simulation" -
tests/test_persistance_integrity.py
r6a7dec1 r02d288d 6 6 import pytest 7 7 8 from flexoentity import FlexOID, EntityType,EntityState9 from tests.conftest import DummyEntity8 from flexoentity import EntityState 9 from builder.questions import RadioQuestion, AnswerOption 10 10 11 11 12 12 # ────────────────────────────────────────────────────────────────────────────── 13 13 @pytest.fixture 14 def approved_ entity():15 """ A fully published dummy entityfor persistence tests."""16 e = DummyEntity(17 domain= "AF",18 etype= EntityType.QUESTION,14 def approved_question(domain): 15 """Provide a fully approved and published RadioQuestion for persistence tests.""" 16 q = RadioQuestion( 17 domain=domain, 18 etype=None, # RadioQuestion sets this internally to EntityType.QUESTION 19 19 state=EntityState.DRAFT, 20 seed="What is Ohm’s law?" 20 text="What is Ohm’s law?", 21 options=[ 22 AnswerOption(text="U = R × I", points=1), 23 AnswerOption(text="U = I / R", points=0), 24 AnswerOption(text="R = U × I", points=0), 25 ], 21 26 ) 22 e.approve()23 e.sign()24 e.publish()25 return e27 q.approve() 28 q.sign() 29 q.publish() 30 return q 26 31 27 @pytest.mark.skip(reason="FlexOIDs are regenerated on import; enable once JSON format is stable") 28 def test_json_roundtrip_preserves_integrity(approved_entity): 32 33 @pytest.mark.skip(reason="FlexOIDs regenerated on import; enable once JSON format is stable") 34 def test_json_roundtrip_preserves_integrity(approved_question): 29 35 """ 30 Export to JSON and reload — ensure fingerprints remain valid.36 Export to JSON and reload — ensure fingerprints and signatures remain valid. 31 37 """ 32 json_str = approved_ entity.to_json()33 loaded = approved_entity.__class__.from_json(json_str)38 json_str = approved_question.to_json() 39 loaded = RadioQuestion.from_json(json_str) 34 40 35 41 # Fingerprint and state should match — integrity must pass 36 assert approved_entity.__class__.verify_integrity(loaded)42 assert RadioQuestion.verify_integrity(loaded) 37 43 38 44 # Metadata should be preserved exactly 39 assert approved_entity.flexo_id.signature == loaded.flexo_id.signature 40 assert approved_entity.flexo_id == loaded.flexo_id 41 assert loaded.state == approved_entity.state 45 assert approved_question.signature == loaded.signature 46 assert approved_question.flexo_id == loaded.flexo_id 47 assert loaded.state == approved_question.state 48 42 49 43 50 # ────────────────────────────────────────────────────────────────────────────── 44 51 45 @pytest.mark.skip(reason="FlexOIDs regenerated on import; tampering detection not applicable yet")46 def test_json_tampering_detection(approved_ entity):52 @pytest.mark.skip(reason="FlexOIDs regenerated on import; tampering detection not yet implemented") 53 def test_json_tampering_detection(approved_question): 47 54 """Tampering with content should invalidate fingerprint verification.""" 48 json_str = approved_ entity.to_json()49 tampered _data= json.loads(json_str)50 tampered _data["text_seed"] = "Tampered content injection"51 tampered_json = json.dumps(tampered _data)55 json_str = approved_question.to_json() 56 tampered = json.loads(json_str) 57 tampered["text"] = "Tampered content injection" 58 tampered_json = json.dumps(tampered) 52 59 53 # We use DummyEntity.from_json to reconstruct (FlexoEntity is abstract) 54 loaded = approved_entity.__class__.from_json(tampered_json) 55 assert not approved_entity.__class__.verify_integrity(loaded) 60 loaded = RadioQuestion.from_json(tampered_json) 61 assert not RadioQuestion.verify_integrity(loaded) 56 62 57 63 … … 59 65 60 66 @pytest.mark.skip(reason="FlexOIDs regenerated on import; corruption detection not yet applicable") 61 def test_json_file_corruption(approved_ entity, tmp_path):67 def test_json_file_corruption(approved_question, tmp_path): 62 68 """Simulate file corruption — integrity check must fail.""" 63 file = tmp_path / " entity.json"64 json_str = approved_ entity.to_json()69 file = tmp_path / "question.json" 70 json_str = approved_question.to_json() 65 71 file.write_text(json_str) 66 72 67 # Corrupt the file 73 # Corrupt the file (simulate accidental byte modification) 68 74 corrupted = json_str.replace("Ohm’s", "Omm’s") 69 75 file.write_text(corrupted) 70 76 71 loaded = approved_entity.__class__.from_json(file.read_text())72 assert not approved_entity.__class__.verify_integrity(loaded)77 loaded = RadioQuestion.from_json(file.read_text()) 78 assert not RadioQuestion.verify_integrity(loaded)
Note:
See TracChangeset
for help on using the changeset viewer.
