import gzip,hashlib,json,tempfile,unittest from pathlib import Path from unittest.mock import patch from netplan_v4.store import PlannerStore from netplan_v4 import retention from test_v4 import AID NOW=1791028800 class RetentionTest(unittest.TestCase): def setUp(self): self.tmp=tempfile.TemporaryDirectory();self.root=Path(self.tmp.name);self.s=PlannerStore(str(self.root/'db.sqlite'));self.archive=self.root/'archive' def tearDown(self):self.s.close();self.tmp.cleanup() def old(self,ago=121,received_ago=10): v='{"numeric":123}';fp=hashlib.sha256(v.encode()).hexdigest() with self.s.con: self.s.con.execute('INSERT INTO planner_observations VALUES(?,?,?,?,?,?)',(AID,'ds',NOW-ago*86400,NOW-received_ago*86400,fp,v)) self.s.con.execute('INSERT INTO planner_observation_origins VALUES(?,?,?,?,?,?,?)',(AID,'ds',NOW-ago*86400,'m','i','e',NOW-received_ago*86400)) def count(self):return self.s.con.execute('SELECT COUNT(*) FROM planner_observations').fetchone()[0] def test_lossless_archive_before_removal(self): self.old();r=retention.archive_batch(self.s.con,self.archive,NOW,'observations');self.assertEqual(r['archived'],1);self.assertEqual(self.count(),0) raw=gzip.decompress((self.archive/r['file']).read_bytes());row=json.loads(raw) self.assertEqual(row['row']['value'],'{"numeric":123}');self.assertEqual(row['origins'][0]['received_mapping'],'m') self.assertEqual(hashlib.sha256(raw).hexdigest(),r['sha256']) def test_recent_and_late_received_rows_not_pruned(self): self.old(ago=100);self.old(ago=121,received_ago=1) self.assertEqual(retention.archive_batch(self.s.con,self.archive,NOW,'observations')['archived'],0);self.assertEqual(self.count(),2) def test_failed_durability_keeps_original(self): self.old() with patch('netplan_v4.retention.os.fsync',side_effect=OSError('synthetic')): with self.assertRaises(OSError):retention.archive_batch(self.s.con,self.archive,NOW,'observations') self.assertEqual(self.count(),1);self.assertEqual(self.s.con.execute('SELECT COUNT(*) FROM planner_observation_origins').fetchone()[0],1) def test_symlink_archive_rejected(self): self.old();other=self.root/'other';other.mkdir();self.archive.symlink_to(other) with self.assertRaises(ValueError):retention.archive_batch(self.s.con,self.archive,NOW,'observations') self.assertEqual(self.count(),1) def test_current_and_acknowledged_plans_retained(self): with self.s.con: for key in ('old','current','ack'): self.s.con.execute('INSERT INTO planner_plans VALUES(?,?,?,?,?,?)',(key,AID,0,'shadow','{}','2020-01-01T00:00:00+00:00')) self.s.con.execute('INSERT INTO planner_current VALUES(?,?,?)',(AID,'shadow','current')) self.s.con.execute('INSERT INTO planner_ack VALUES(?,?,?,?,?,?)',(AID,'ack',0,'2020-01-01T00:00:00+00:00',None,'shadow_seen')) r=retention.archive_batch(self.s.con,self.archive,NOW,'plans');self.assertEqual(r['archived'],1) self.assertEqual({r[0] for r in self.s.con.execute('SELECT plan_id FROM planner_plans')},{'current','ack'}) def test_archived_rows_can_be_restored_exactly(self): self.old();r=retention.archive_batch(self.s.con,self.archive,NOW,'observations') obj=json.loads(gzip.decompress((self.archive/r['file']).read_bytes()));row=obj['row'] with self.s.con:self.s.con.execute('INSERT INTO planner_observations VALUES(?,?,?,?,?,?)',tuple(row[k] for k in ('plant','dataset','captured_at','received_at','fingerprint','value'))) self.assertEqual(self.count(),1) self.assertEqual(self.s.con.execute('SELECT fingerprint FROM planner_observations').fetchone()[0],hashlib.sha256(row['value'].encode()).hexdigest()) def test_no_duplicate_archive_after_repeat(self): self.old();retention.archive_batch(self.s.con,self.archive,NOW,'observations') self.assertEqual(retention.archive_batch(self.s.con,self.archive,NOW,'observations')['archived'],0) self.assertEqual(len(list(self.archive.glob('*.gz'))),1) if __name__=='__main__':unittest.main()