A Transparent and Verifiable Way to Sync the AT Protocol's PLC Directory

basic tests

+769 -11
+26
README.md
··· 1 1 # PLC Bundle 2 2 3 + ``` 4 + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠄⠀⡀⠀⠀⠀⠀⠀⠀⢀⠀⠀⡀⠀⢀⠀⢀⡀⣤⡢⣤⡤⡀⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 5 + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⡄⡄⠐⡀⠈⣀⠀⡠⡠⠀⣢⣆⢌⡾⢙⠺⢽⠾⡋⣻⡷⡫⢵⣭⢦⣴⠦⠀⢠⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 6 + ⠀⠀⠀⠀⠀⠀⠀⠀⢠⣤⣽⣥⡈⠧⣂⢧⢾⠕⠞⠡⠊⠁⣐⠉⠀⠉⢍⠀⠉⠌⡉⠀⠂⠁⠱⠉⠁⢝⠻⠎⣬⢌⡌⣬⣡⣀⣢⣄⡄⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀ 7 + ⠀⠀⠀⠀⠀⠀⠀⢀⢸⣿⣿⢿⣾⣯⣑⢄⡂⠀⠄⠂⠀⠀⢀⠀⠀⠐⠀⠀⠀⠀⠀⠀⠀⠀⠄⠐⠀⠀⠀⠀⣄⠭⠂⠈⠜⣩⣿⢝⠃⠀⠁⠀⠀⠀⠀⠀⠀⠀⠀ 8 + ⠀⠀⠀⠀⠀⠀⠀⢀⣻⡟⠏⠀⠚⠈⠚⡉⡝⢶⣱⢤⣅⠈⠀⠄⠀⠀⠀⠀⠀⠠⠀⠀⡂⠐⣤⢕⡪⢼⣈⡹⡇⠏⠏⠋⠅⢃⣪⡏⡇⡍⠀⠀⠀⠀⠀⠀⠀⠀⠀ 9 + ⠀⠀⠀⠀⠀⠀⠀⠀⠺⣻⡄⠀⠀⠀⢠⠌⠃⠐⠉⢡⠱⠧⠝⡯⣮⢶⣴⣤⡆⢐⣣⢅⣮⡟⠦⠍⠉⠀⠁⠐⠀⠀⠀⠄⠐⠡⣽⡸⣎⢁⠀⠀⠀⠀⠀⠀⠀⠀⠀ 10 + ⠀⠀⠀⠀⠀⠀⠀⢈⡻⣧⠀⠁⠐⠀⠀⠀⠀⠀⠀⠊⠀⠕⢀⡉⠈⡫⠽⡿⡟⠿⠟⠁⠀⠀⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠬⠥⣋⡯⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 11 + ⠀⠀⠀⠀⠀⠀⠀⡀⣾⡍⠕⡀⠀⠀⠀⠄⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠥⣤⢌⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠀⠀⠄⢀⠀⢝⢞⣫⡆⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀ 12 + ⠀⠀⠀⠀⠀⠀⠀⠀⣽⡶⡄⠐⡀⠀⠀⠀⠀⠀⠀⢀⠀⠄⠀⠀⠀⠄⠁⠇⣷⡆⠀⠀⠀⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡸⢝⣮⠍⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 13 + ⠀⠀⠀⠀⠀⠀⢀⠀⢾⣷⠀⠠⡀⠀⠀⠀⠀⢀⠀⠀⠀⠀⠀⠁⡁⠀⠀⣾⡥⠖⠀⠀⠀⠂⠀⠀⠀⠀⠀⠁⠀⡀⠁⠀⠀⠻⢳⣻⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 14 + ⠀⠀⠀⠀⠀⠀⠀⠀⣞⡙⠨⣀⠠⠄⠀⠂⠀⠀⠀⠈⢀⠀⠀⠀⠀⠀⠤⢚⢢⣟⠀⠀⠀⠀⡐⠀⠀⡀⠀⠀⠀⠀⠁⠈⠌⠊⣯⣮⡏⠡⠂⠀⠀⠀⠀⠀⠀⠀⠀ 15 + ⠀⠀⠀⠀⠀⠀⠀⠀⣻⡟⡄⡡⣄⠀⠠⠀⠀⡅⠀⠐⠀⡀⠀⡀⠀⠄⠈⠃⠳⠪⠤⠀⠀⠀⠀⡀⠀⠂⠀⠀⠀⠁⠈⢠⣠⠒⠻⣻⡧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 16 + ⠀⠀⠀⠀⠀⠀⠀⠀⠪⡎⠠⢌⠑⡀⠂⠀⠄⠠⠀⠠⠀⠁⡀⠠⠠⡀⣀⠜⢏⡅⠀⠀⡀⠁⠀⠀⠁⠁⠐⠄⡀⢀⠂⠀⠄⢑⣿⣿⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 17 + ⠀⠀⠀⠀⠀⠀⠀⠀⠼⣻⠧⣣⣀⠐⠨⠁⠕⢈⢀⢀⡁⠀⠈⠠⢀⠀⠐⠜⣽⡗⡤⠀⠂⠀⠠⠀⢂⠠⠀⠁⠄⠀⠔⠀⠑⣨⣿⢯⠋⡅⠀⠀⠀⠀⠀⠀⠀⠀⠀ 18 + ⠀⠀⠀⠀⠀⠀⠀⠀⡚⣷⣭⠎⢃⡗⠄⡄⢀⠁⠀⠅⢀⢅⡀⠠⠀⢠⡀⡩⠷⢇⠀⡀⠄⡠⠤⠆⣀⡀⠄⠉⣠⠃⠴⠀⠈⢁⣿⡛⡯⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 19 + ⠀⠀⠀⠀⠀⠀⠀⠘⡬⡿⣿⡏⡻⡯⠌⢁⢛⠠⠓⠐⠐⠐⠌⠃⠋⠂⡢⢰⣈⢏⣰⠂⠈⠀⠠⠒⠡⠌⠫⠭⠩⠢⡬⠆⠿⢷⢿⡽⡧⠉⠊⠀⠀⠀⠀⠀⠀⠀⠀ 20 + ⠀⠀⠀⠀⠀⠀⠀⠀⠺⣷⣺⣗⣿⡶⡎⡅⣣⢎⠠⡅⣢⡖⠴⠬⡈⠂⡨⢡⠾⣣⣢⠀⠀⡹⠄⡄⠄⡇⣰⡖⡊⠔⢹⣄⣿⣭⣵⣿⢷⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 21 + ⠀⠀⠀⠀⠀⠀⠀⠀⠩⣿⣿⣲⣿⣷⣟⣼⠟⣬⢉⡠⣪⢜⣂⣁⠥⠓⠚⡁⢶⣷⣠⠂⡄⡢⣀⡐⠧⢆⣒⡲⡳⡫⢟⡃⢪⡧⣟⡟⣯⠐⠀⠀⠀⠀⠀⠀⠀⠀⠀ 22 + ⠀⠀⠀⠀⠀⠀⠀⠀⢺⠟⢿⢟⢻⡗⡮⡿⣲⢷⣆⣏⣇⡧⣄⢖⠾⡷⣿⣤⢳⢷⣣⣦⡜⠗⣭⢂⠩⣹⢿⡲⢎⡧⣕⣖⣓⣽⡿⡖⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 23 + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠂⠂⠏⠿⢻⣥⡪⢽⣳⣳⣥⡶⣫⣍⢐⣥⣻⣾⡻⣅⢭⡴⢭⣿⠕⣧⡭⣞⣻⣣⣻⢿⠟⠛⠙⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 24 + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠄⠋⠫⠯⣍⢻⣿⣿⣷⣕⣵⣹⣽⣿⣷⣇⡏⣿⡿⣍⡝⠵⠯⠁⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 25 + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠐⠠⠁⠋⢣⠓⡍⣫⠹⣿⣿⣷⡿⠯⠺⠁⠁⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 26 + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠋⢀⠋⢈⡿⠿⠁⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 27 + ``` 28 + 3 29 A Go library and CLI tool for managing [DID PLC Directory](https://plc.directory/) bundles with transparent synchronization, compression, and verification. 4 30 5 31 ## Features
+437
bundle/bundle_test.go
··· 1 + package bundle_test 2 + 3 + import ( 4 + "path/filepath" 5 + "testing" 6 + "time" 7 + 8 + "github.com/atscan/plcbundle/bundle" 9 + "github.com/atscan/plcbundle/plc" 10 + ) 11 + 12 + // TestIndex tests index operations 13 + func TestIndex(t *testing.T) { 14 + t.Run("CreateNewIndex", func(t *testing.T) { 15 + idx := bundle.NewIndex() 16 + if idx == nil { 17 + t.Fatal("NewIndex returned nil") 18 + } 19 + if idx.Version != bundle.INDEX_VERSION { 20 + t.Errorf("expected version %s, got %s", bundle.INDEX_VERSION, idx.Version) 21 + } 22 + if idx.Count() != 0 { 23 + t.Errorf("expected empty index, got count %d", idx.Count()) 24 + } 25 + }) 26 + 27 + t.Run("AddBundle", func(t *testing.T) { 28 + idx := bundle.NewIndex() 29 + meta := &bundle.BundleMetadata{ 30 + BundleNumber: 1, 31 + StartTime: time.Now(), 32 + EndTime: time.Now().Add(time.Hour), 33 + OperationCount: bundle.BUNDLE_SIZE, 34 + DIDCount: 1000, 35 + Hash: "abc123", 36 + CompressedHash: "def456", 37 + } 38 + 39 + idx.AddBundle(meta) 40 + 41 + if idx.Count() != 1 { 42 + t.Errorf("expected count 1, got %d", idx.Count()) 43 + } 44 + 45 + retrieved, err := idx.GetBundle(1) 46 + if err != nil { 47 + t.Fatalf("GetBundle failed: %v", err) 48 + } 49 + if retrieved.Hash != meta.Hash { 50 + t.Errorf("expected hash %s, got %s", meta.Hash, retrieved.Hash) 51 + } 52 + }) 53 + 54 + t.Run("SaveAndLoad", func(t *testing.T) { 55 + tmpDir := t.TempDir() 56 + indexPath := filepath.Join(tmpDir, "test_index.json") 57 + 58 + // Create and save 59 + idx := bundle.NewIndex() 60 + idx.AddBundle(&bundle.BundleMetadata{ 61 + BundleNumber: 1, 62 + StartTime: time.Now(), 63 + EndTime: time.Now().Add(time.Hour), 64 + OperationCount: bundle.BUNDLE_SIZE, 65 + Hash: "test123", 66 + }) 67 + 68 + if err := idx.Save(indexPath); err != nil { 69 + t.Fatalf("Save failed: %v", err) 70 + } 71 + 72 + // Load 73 + loaded, err := bundle.LoadIndex(indexPath) 74 + if err != nil { 75 + t.Fatalf("LoadIndex failed: %v", err) 76 + } 77 + 78 + if loaded.Count() != 1 { 79 + t.Errorf("expected count 1, got %d", loaded.Count()) 80 + } 81 + }) 82 + 83 + t.Run("GetBundleRange", func(t *testing.T) { 84 + idx := bundle.NewIndex() 85 + for i := 1; i <= 5; i++ { 86 + idx.AddBundle(&bundle.BundleMetadata{ 87 + BundleNumber: i, 88 + StartTime: time.Now(), 89 + EndTime: time.Now().Add(time.Hour), 90 + OperationCount: bundle.BUNDLE_SIZE, 91 + }) 92 + } 93 + 94 + bundles := idx.GetBundleRange(2, 4) 95 + if len(bundles) != 3 { 96 + t.Errorf("expected 3 bundles, got %d", len(bundles)) 97 + } 98 + if bundles[0].BundleNumber != 2 || bundles[2].BundleNumber != 4 { 99 + t.Errorf("unexpected bundle range") 100 + } 101 + }) 102 + 103 + t.Run("FindGaps", func(t *testing.T) { 104 + idx := bundle.NewIndex() 105 + // Add bundles 1, 2, 4, 5 (missing 3) 106 + for _, num := range []int{1, 2, 4, 5} { 107 + idx.AddBundle(&bundle.BundleMetadata{ 108 + BundleNumber: num, 109 + StartTime: time.Now(), 110 + EndTime: time.Now().Add(time.Hour), 111 + OperationCount: bundle.BUNDLE_SIZE, 112 + }) 113 + } 114 + 115 + gaps := idx.FindGaps() 116 + if len(gaps) != 1 { 117 + t.Errorf("expected 1 gap, got %d", len(gaps)) 118 + } 119 + if len(gaps) > 0 && gaps[0] != 3 { 120 + t.Errorf("expected gap at 3, got %d", gaps[0]) 121 + } 122 + }) 123 + } 124 + 125 + // TestBundle tests bundle operations 126 + func TestBundle(t *testing.T) { 127 + t.Run("ValidateForSave", func(t *testing.T) { 128 + tests := []struct { 129 + name string 130 + bundle *bundle.Bundle 131 + wantErr bool 132 + }{ 133 + { 134 + name: "valid bundle", 135 + bundle: &bundle.Bundle{ 136 + BundleNumber: 1, 137 + StartTime: time.Now(), 138 + EndTime: time.Now().Add(time.Hour), 139 + Operations: makeTestOperations(bundle.BUNDLE_SIZE), 140 + }, 141 + wantErr: false, 142 + }, 143 + { 144 + name: "invalid bundle number", 145 + bundle: &bundle.Bundle{ 146 + BundleNumber: 0, 147 + Operations: makeTestOperations(bundle.BUNDLE_SIZE), 148 + }, 149 + wantErr: true, 150 + }, 151 + { 152 + name: "wrong operation count", 153 + bundle: &bundle.Bundle{ 154 + BundleNumber: 1, 155 + Operations: makeTestOperations(100), 156 + }, 157 + wantErr: true, 158 + }, 159 + { 160 + name: "start after end", 161 + bundle: &bundle.Bundle{ 162 + BundleNumber: 1, 163 + StartTime: time.Now().Add(time.Hour), 164 + EndTime: time.Now(), 165 + Operations: makeTestOperations(bundle.BUNDLE_SIZE), 166 + }, 167 + wantErr: true, 168 + }, 169 + } 170 + 171 + for _, tt := range tests { 172 + t.Run(tt.name, func(t *testing.T) { 173 + err := tt.bundle.ValidateForSave() 174 + if (err != nil) != tt.wantErr { 175 + t.Errorf("ValidateForSave() error = %v, wantErr %v", err, tt.wantErr) 176 + } 177 + }) 178 + } 179 + }) 180 + 181 + t.Run("CompressionRatio", func(t *testing.T) { 182 + b := &bundle.Bundle{ 183 + CompressedSize: 1000, 184 + UncompressedSize: 5000, 185 + } 186 + ratio := b.CompressionRatio() 187 + if ratio != 5.0 { 188 + t.Errorf("expected ratio 5.0, got %f", ratio) 189 + } 190 + }) 191 + } 192 + 193 + // TestMempool tests mempool operations 194 + func TestMempool(t *testing.T) { 195 + tmpDir := t.TempDir() 196 + logger := &testLogger{t: t} 197 + 198 + t.Run("CreateAndAdd", func(t *testing.T) { 199 + minTime := time.Now().Add(-time.Hour) 200 + m, err := bundle.NewMempool(tmpDir, 1, minTime, logger) 201 + if err != nil { 202 + t.Fatalf("NewMempool failed: %v", err) 203 + } 204 + 205 + ops := makeTestOperations(100) 206 + added, err := m.Add(ops) 207 + if err != nil { 208 + t.Fatalf("Add failed: %v", err) 209 + } 210 + if added != 100 { 211 + t.Errorf("expected 100 added, got %d", added) 212 + } 213 + if m.Count() != 100 { 214 + t.Errorf("expected count 100, got %d", m.Count()) 215 + } 216 + }) 217 + 218 + t.Run("ChronologicalValidation", func(t *testing.T) { 219 + minTime := time.Now().Add(-time.Hour) 220 + m, err := bundle.NewMempool(tmpDir, 2, minTime, logger) 221 + if err != nil { 222 + t.Fatalf("NewMempool failed: %v", err) 223 + } 224 + 225 + // Add operations in order 226 + ops := makeTestOperations(10) 227 + _, err = m.Add(ops) 228 + if err != nil { 229 + t.Fatalf("Add failed: %v", err) 230 + } 231 + 232 + // Try to add operation before last one (should fail) 233 + oldOp := []plc.PLCOperation{ 234 + { 235 + DID: "did:plc:old", 236 + CID: "old123", 237 + CreatedAt: time.Now().Add(-2 * time.Hour), 238 + }, 239 + } 240 + _, err = m.Add(oldOp) 241 + if err == nil { 242 + t.Error("expected chronological validation error") 243 + } 244 + }) 245 + 246 + t.Run("TakeOperations", func(t *testing.T) { 247 + minTime := time.Now().Add(-time.Hour) 248 + m, err := bundle.NewMempool(tmpDir, 3, minTime, logger) 249 + if err != nil { 250 + t.Fatalf("NewMempool failed: %v", err) 251 + } 252 + 253 + ops := makeTestOperations(100) 254 + m.Add(ops) 255 + 256 + taken, err := m.Take(50) 257 + if err != nil { 258 + t.Fatalf("Take failed: %v", err) 259 + } 260 + if len(taken) != 50 { 261 + t.Errorf("expected 50 operations, got %d", len(taken)) 262 + } 263 + if m.Count() != 50 { 264 + t.Errorf("expected 50 remaining, got %d", m.Count()) 265 + } 266 + }) 267 + 268 + t.Run("SaveAndLoad", func(t *testing.T) { 269 + minTime := time.Now().Add(-time.Hour) 270 + m, err := bundle.NewMempool(tmpDir, 4, minTime, logger) 271 + if err != nil { 272 + t.Fatalf("NewMempool failed: %v", err) 273 + } 274 + 275 + ops := makeTestOperations(50) 276 + m.Add(ops) 277 + 278 + if err := m.Save(); err != nil { 279 + t.Fatalf("Save failed: %v", err) 280 + } 281 + 282 + // Create new mempool and load 283 + m2, err := bundle.NewMempool(tmpDir, 4, minTime, logger) 284 + if err != nil { 285 + t.Fatalf("NewMempool failed: %v", err) 286 + } 287 + 288 + if m2.Count() != 50 { 289 + t.Errorf("expected 50 operations after load, got %d", m2.Count()) 290 + } 291 + }) 292 + 293 + t.Run("Validate", func(t *testing.T) { 294 + minTime := time.Now().Add(-time.Hour) 295 + m, err := bundle.NewMempool(tmpDir, 5, minTime, logger) 296 + if err != nil { 297 + t.Fatalf("NewMempool failed: %v", err) 298 + } 299 + 300 + ops := makeTestOperations(10) 301 + m.Add(ops) 302 + 303 + if err := m.Validate(); err != nil { 304 + t.Errorf("Validate failed: %v", err) 305 + } 306 + }) 307 + } 308 + 309 + // TestOperations tests low-level operations 310 + func TestOperations(t *testing.T) { 311 + tmpDir := t.TempDir() 312 + logger := &testLogger{t: t} 313 + 314 + ops, err := bundle.NewOperations(bundle.CompressionBetter, logger) 315 + if err != nil { 316 + t.Fatalf("NewOperations failed: %v", err) 317 + } 318 + defer ops.Close() 319 + 320 + t.Run("SerializeJSONL", func(t *testing.T) { 321 + operations := makeTestOperations(10) 322 + data := ops.SerializeJSONL(operations) 323 + if len(data) == 0 { 324 + t.Error("SerializeJSONL returned empty data") 325 + } 326 + }) 327 + 328 + t.Run("Hash", func(t *testing.T) { 329 + data := []byte("test data") 330 + hash := ops.Hash(data) 331 + if len(hash) != 64 { // SHA256 hex = 64 chars 332 + t.Errorf("expected hash length 64, got %d", len(hash)) 333 + } 334 + 335 + // Same data should produce same hash 336 + hash2 := ops.Hash(data) 337 + if hash != hash2 { 338 + t.Error("same data produced different hashes") 339 + } 340 + }) 341 + 342 + t.Run("SaveAndLoadBundle", func(t *testing.T) { 343 + operations := makeTestOperations(bundle.BUNDLE_SIZE) 344 + path := filepath.Join(tmpDir, "test_bundle.jsonl.zst") 345 + 346 + // Save 347 + uncompHash, compHash, uncompSize, compSize, err := ops.SaveBundle(path, operations) 348 + if err != nil { 349 + t.Fatalf("SaveBundle failed: %v", err) 350 + } 351 + 352 + if uncompHash == "" || compHash == "" { 353 + t.Error("empty hashes returned") 354 + } 355 + if uncompSize == 0 || compSize == 0 { 356 + t.Error("zero sizes returned") 357 + } 358 + if compSize >= uncompSize { 359 + t.Error("compressed size should be smaller than uncompressed") 360 + } 361 + 362 + // Load 363 + loaded, err := ops.LoadBundle(path) 364 + if err != nil { 365 + t.Fatalf("LoadBundle failed: %v", err) 366 + } 367 + 368 + if len(loaded) != len(operations) { 369 + t.Errorf("expected %d operations, got %d", len(operations), len(loaded)) 370 + } 371 + }) 372 + 373 + t.Run("ExtractUniqueDIDs", func(t *testing.T) { 374 + operations := []plc.PLCOperation{ 375 + {DID: "did:plc:1"}, 376 + {DID: "did:plc:2"}, 377 + {DID: "did:plc:1"}, // duplicate 378 + {DID: "did:plc:3"}, 379 + } 380 + 381 + dids := ops.ExtractUniqueDIDs(operations) 382 + if len(dids) != 3 { 383 + t.Errorf("expected 3 unique DIDs, got %d", len(dids)) 384 + } 385 + }) 386 + 387 + t.Run("GetBoundaryCIDs", func(t *testing.T) { 388 + baseTime := time.Now() 389 + operations := []plc.PLCOperation{ 390 + {CID: "cid1", CreatedAt: baseTime}, 391 + {CID: "cid2", CreatedAt: baseTime.Add(time.Second)}, 392 + {CID: "cid3", CreatedAt: baseTime.Add(2 * time.Second)}, 393 + {CID: "cid4", CreatedAt: baseTime.Add(2 * time.Second)}, // same as cid3 394 + {CID: "cid5", CreatedAt: baseTime.Add(2 * time.Second)}, // same as cid3 395 + } 396 + 397 + boundaryTime, cids := ops.GetBoundaryCIDs(operations) 398 + if !boundaryTime.Equal(baseTime.Add(2 * time.Second)) { 399 + t.Error("unexpected boundary time") 400 + } 401 + if len(cids) != 3 { // cid3, cid4, cid5 402 + t.Errorf("expected 3 boundary CIDs, got %d", len(cids)) 403 + } 404 + }) 405 + } 406 + 407 + // Helper functions 408 + 409 + func makeTestOperations(count int) []plc.PLCOperation { 410 + ops := make([]plc.PLCOperation, count) 411 + baseTime := time.Now().Add(-time.Hour) 412 + 413 + for i := 0; i < count; i++ { 414 + ops[i] = plc.PLCOperation{ 415 + DID: "did:plc:test" + string(rune(i)), 416 + CID: "bafytest" + string(rune(i)), 417 + CreatedAt: baseTime.Add(time.Duration(i) * time.Second), 418 + Operation: map[string]interface{}{ 419 + "type": "create", 420 + }, 421 + } 422 + } 423 + 424 + return ops 425 + } 426 + 427 + type testLogger struct { 428 + t *testing.T 429 + } 430 + 431 + func (l *testLogger) Printf(format string, v ...interface{}) { 432 + l.t.Logf(format, v...) 433 + } 434 + 435 + func (l *testLogger) Println(v ...interface{}) { 436 + l.t.Log(v...) 437 + }
+10 -6
cmd/plcbundle/compare.go
··· 135 135 } 136 136 sort.Ints(comparison.ExtraBundles) 137 137 138 - // Find hash mismatches 138 + // Find hash mismatches (compare UNCOMPRESSED hash - the canonical hash) 139 139 for bundleNum, localMeta := range localMap { 140 140 if targetMeta, exists := targetMap[bundleNum]; exists { 141 141 comparison.CommonCount++ 142 - if localMeta.CompressedHash != targetMeta.CompressedHash { 142 + if localMeta.Hash != targetMeta.Hash { 143 143 comparison.HashMismatches = append(comparison.HashMismatches, HashMismatch{ 144 144 BundleNumber: bundleNum, 145 - LocalHash: localMeta.CompressedHash, 146 - TargetHash: targetMeta.CompressedHash, 145 + LocalHash: localMeta.Hash, 146 + TargetHash: targetMeta.Hash, 147 147 }) 148 148 } 149 149 } ··· 232 232 // Hash mismatches 233 233 if len(c.HashMismatches) > 0 { 234 234 fmt.Printf("\n") 235 - fmt.Printf("Hash Mismatches\n") 236 - fmt.Printf("───────────────\n") 235 + fmt.Printf("Hash Mismatches (uncompressed data)\n") 236 + fmt.Printf("────────────────────────────────────\n") 237 237 238 238 displayCount := len(c.HashMismatches) 239 239 if displayCount > 10 && !verbose { ··· 245 245 fmt.Printf(" Bundle %06d:\n", m.BundleNumber) 246 246 fmt.Printf(" Local: %s\n", m.LocalHash[:16]+"...") 247 247 fmt.Printf(" Target: %s\n", m.TargetHash[:16]+"...") 248 + if verbose { 249 + fmt.Printf(" Local (full): %s\n", m.LocalHash) 250 + fmt.Printf(" Target (full): %s\n", m.TargetHash) 251 + } 248 252 } 249 253 250 254 if len(c.HashMismatches) > displayCount {
+29 -4
cmd/plcbundle/server.go
··· 244 244 baseURL := getBaseURL(r) 245 245 wsURL := getWSURL(r) 246 246 247 - fmt.Fprint(w, ` 248 - ||||| PLC Bundle Server ||||| 247 + fmt.Fprintf(w, ` 248 + 249 + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠄⠀⡀⠀⠀⠀⠀⠀⠀⢀⠀⠀⡀⠀⢀⠀⢀⡀⣤⡢⣤⡤⡀⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 250 + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⡄⡄⠐⡀⠈⣀⠀⡠⡠⠀⣢⣆⢌⡾⢙⠺⢽⠾⡋⣻⡷⡫⢵⣭⢦⣴⠦⠀⢠⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 251 + ⠀⠀⠀⠀⠀⠀⠀⠀⢠⣤⣽⣥⡈⠧⣂⢧⢾⠕⠞⠡⠊⠁⣐⠉⠀⠉⢍⠀⠉⠌⡉⠀⠂⠁⠱⠉⠁⢝⠻⠎⣬⢌⡌⣬⣡⣀⣢⣄⡄⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀ 252 + ⠀⠀⠀⠀⠀⠀⠀⢀⢸⣿⣿⢿⣾⣯⣑⢄⡂⠀⠄⠂⠀⠀⢀⠀⠀⠐⠀⠀⠀⠀⠀⠀⠀⠀⠄⠐⠀⠀⠀⠀⣄⠭⠂⠈⠜⣩⣿⢝⠃⠀⠁⠀⠀⠀⠀⠀⠀⠀⠀ 253 + ⠀⠀⠀⠀⠀⠀⠀⢀⣻⡟⠏⠀⠚⠈⠚⡉⡝⢶⣱⢤⣅⠈⠀⠄⠀⠀⠀⠀⠀⠠⠀⠀⡂⠐⣤⢕⡪⢼⣈⡹⡇⠏⠏⠋⠅⢃⣪⡏⡇⡍⠀⠀⠀⠀⠀⠀⠀⠀⠀ 254 + ⠀⠀⠀⠀⠀⠀⠀⠀⠺⣻⡄⠀⠀⠀⢠⠌⠃⠐⠉⢡⠱⠧⠝⡯⣮⢶⣴⣤⡆⢐⣣⢅⣮⡟⠦⠍⠉⠀⠁⠐⠀⠀⠀⠄⠐⠡⣽⡸⣎⢁⠀⠀⠀⠀⠀⠀⠀⠀⠀ 255 + ⠀⠀⠀⠀⠀⠀⠀⢈⡻⣧⠀⠁⠐⠀⠀⠀⠀⠀⠀⠊⠀⠕⢀⡉⠈⡫⠽⡿⡟⠿⠟⠁⠀⠀⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠬⠥⣋⡯⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 256 + ⠀⠀⠀⠀⠀⠀⠀⡀⣾⡍⠕⡀⠀⠀⠀⠄⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠥⣤⢌⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠀⠀⠄⢀⠀⢝⢞⣫⡆⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀ 257 + ⠀⠀⠀⠀⠀⠀⠀⠀⣽⡶⡄⠐⡀⠀⠀⠀⠀⠀⠀⢀⠀⠄⠀⠀⠀⠄⠁⠇⣷⡆⠀⠀⠀⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡸⢝⣮⠍⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 258 + ⠀⠀⠀⠀⠀⠀⢀⠀⢾⣷⠀⠠⡀⠀⠀⠀⠀⢀⠀⠀⠀⠀⠀⠁⡁⠀⠀⣾⡥⠖⠀⠀⠀⠂⠀⠀⠀⠀⠀⠁⠀⡀⠁⠀⠀⠻⢳⣻⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 259 + ⠀⠀⠀⠀⠀⠀⠀⠀⣞⡙⠨⣀⠠⠄⠀⠂⠀⠀⠀⠈⢀⠀⠀⠀⠀⠀⠤⢚⢢⣟⠀⠀⠀⠀⡐⠀⠀⡀⠀⠀⠀⠀⠁⠈⠌⠊⣯⣮⡏⠡⠂⠀⠀⠀⠀⠀⠀⠀⠀ 260 + ⠀⠀⠀⠀⠀⠀⠀⠀⣻⡟⡄⡡⣄⠀⠠⠀⠀⡅⠀⠐⠀⡀⠀⡀⠀⠄⠈⠃⠳⠪⠤⠀⠀⠀⠀⡀⠀⠂⠀⠀⠀⠁⠈⢠⣠⠒⠻⣻⡧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 261 + ⠀⠀⠀⠀⠀⠀⠀⠀⠪⡎⠠⢌⠑⡀⠂⠀⠄⠠⠀⠠⠀⠁⡀⠠⠠⡀⣀⠜⢏⡅⠀⠀⡀⠁⠀⠀⠁⠁⠐⠄⡀⢀⠂⠀⠄⢑⣿⣿⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 262 + ⠀⠀⠀⠀⠀⠀⠀⠀⠼⣻⠧⣣⣀⠐⠨⠁⠕⢈⢀⢀⡁⠀⠈⠠⢀⠀⠐⠜⣽⡗⡤⠀⠂⠀⠠⠀⢂⠠⠀⠁⠄⠀⠔⠀⠑⣨⣿⢯⠋⡅⠀⠀⠀⠀⠀⠀⠀⠀⠀ 263 + ⠀⠀⠀⠀⠀⠀⠀⠀⡚⣷⣭⠎⢃⡗⠄⡄⢀⠁⠀⠅⢀⢅⡀⠠⠀⢠⡀⡩⠷⢇⠀⡀⠄⡠⠤⠆⣀⡀⠄⠉⣠⠃⠴⠀⠈⢁⣿⡛⡯⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 264 + ⠀⠀⠀⠀⠀⠀⠀⠘⡬⡿⣿⡏⡻⡯⠌⢁⢛⠠⠓⠐⠐⠐⠌⠃⠋⠂⡢⢰⣈⢏⣰⠂⠈⠀⠠⠒⠡⠌⠫⠭⠩⠢⡬⠆⠿⢷⢿⡽⡧⠉⠊⠀⠀⠀⠀⠀⠀⠀⠀ 265 + ⠀⠀⠀⠀⠀⠀⠀⠀⠺⣷⣺⣗⣿⡶⡎⡅⣣⢎⠠⡅⣢⡖⠴⠬⡈⠂⡨⢡⠾⣣⣢⠀⠀⡹⠄⡄⠄⡇⣰⡖⡊⠔⢹⣄⣿⣭⣵⣿⢷⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 266 + ⠀⠀⠀⠀⠀⠀⠀⠀⠩⣿⣿⣲⣿⣷⣟⣼⠟⣬⢉⡠⣪⢜⣂⣁⠥⠓⠚⡁⢶⣷⣠⠂⡄⡢⣀⡐⠧⢆⣒⡲⡳⡫⢟⡃⢪⡧⣟⡟⣯⠐⠀⠀⠀⠀⠀⠀⠀⠀⠀ 267 + ⠀⠀⠀⠀⠀⠀⠀⠀⢺⠟⢿⢟⢻⡗⡮⡿⣲⢷⣆⣏⣇⡧⣄⢖⠾⡷⣿⣤⢳⢷⣣⣦⡜⠗⣭⢂⠩⣹⢿⡲⢎⡧⣕⣖⣓⣽⡿⡖⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 268 + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠂⠂⠏⠿⢻⣥⡪⢽⣳⣳⣥⡶⣫⣍⢐⣥⣻⣾⡻⣅⢭⡴⢭⣿⠕⣧⡭⣞⣻⣣⣻⢿⠟⠛⠙⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 269 + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠄⠋⠫⠯⣍⢻⣿⣿⣷⣕⣵⣹⣽⣿⣷⣇⡏⣿⡿⣍⡝⠵⠯⠁⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 270 + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠐⠠⠁⠋⢣⠓⡍⣫⠹⣿⣿⣷⡿⠯⠺⠁⠁⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 271 + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠋⢀⠋⢈⡿⠿⠁⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 272 + 273 + plcbundle server (%s) 249 274 250 - `) 275 + `, version) 251 276 252 277 fmt.Fprintf(w, "What is PLC Bundle?\n") 253 278 fmt.Fprintf(w, "━━━━━━━━━━━━━━━━━━━━\n") ··· 375 400 } 376 401 377 402 fmt.Fprintf(w, "\n────────────────────────────────────────────────────────────────\n") 378 - fmt.Fprintf(w, "plcbundle v%s | https://github.com/atscan/plcbundle\n", version) 403 + fmt.Fprintf(w, "plcbundle %s | https://github.com/atscan/plcbundle\n", version) 379 404 } 380 405 381 406 // handleSync returns sync status and mempool info as JSON
+1 -1
go.mod
··· 4 4 5 5 require github.com/klauspost/compress v1.18.1 6 6 7 - require github.com/gorilla/websocket v1.5.3 // indirect 7 + require github.com/gorilla/websocket v1.5.3
+259
plc/plc_test.go
··· 1 + package plc_test 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "net/http" 7 + "net/http/httptest" 8 + "testing" 9 + "time" 10 + 11 + "github.com/atscan/plcbundle/bundle" 12 + "github.com/atscan/plcbundle/plc" 13 + ) 14 + 15 + // TestPLCOperation tests operation parsing and methods 16 + func TestPLCOperation(t *testing.T) { 17 + t.Run("IsNullified", func(t *testing.T) { 18 + tests := []struct { 19 + name string 20 + nullified interface{} 21 + want bool 22 + }{ 23 + {"nil", nil, false}, 24 + {"false", false, false}, 25 + {"true", true, true}, 26 + {"empty string", "", false}, 27 + {"non-empty string", "cid123", true}, 28 + } 29 + 30 + for _, tt := range tests { 31 + t.Run(tt.name, func(t *testing.T) { 32 + op := plc.PLCOperation{Nullified: tt.nullified} 33 + if got := op.IsNullified(); got != tt.want { 34 + t.Errorf("IsNullified() = %v, want %v", got, tt.want) 35 + } 36 + }) 37 + } 38 + }) 39 + 40 + t.Run("GetNullifyingCID", func(t *testing.T) { 41 + op := plc.PLCOperation{Nullified: "bafytest123"} 42 + if cid := op.GetNullifyingCID(); cid != "bafytest123" { 43 + t.Errorf("expected 'bafytest123', got '%s'", cid) 44 + } 45 + 46 + op2 := plc.PLCOperation{Nullified: true} 47 + if cid := op2.GetNullifyingCID(); cid != "" { 48 + t.Errorf("expected empty string, got '%s'", cid) 49 + } 50 + }) 51 + 52 + t.Run("JSONParsing", func(t *testing.T) { 53 + jsonData := `{ 54 + "did": "did:plc:test123", 55 + "cid": "bafytest", 56 + "createdAt": "2024-01-01T12:00:00.000Z", 57 + "operation": {"type": "create"}, 58 + "nullified": false 59 + }` 60 + 61 + var op plc.PLCOperation 62 + if err := json.Unmarshal([]byte(jsonData), &op); err != nil { 63 + t.Fatalf("failed to parse operation: %v", err) 64 + } 65 + 66 + if op.DID != "did:plc:test123" { 67 + t.Errorf("unexpected DID: %s", op.DID) 68 + } 69 + if op.CID != "bafytest" { 70 + t.Errorf("unexpected CID: %s", op.CID) 71 + } 72 + }) 73 + } 74 + 75 + // TestClient tests PLC client operations 76 + func TestClient(t *testing.T) { 77 + t.Run("Export", func(t *testing.T) { 78 + // Create mock server 79 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 80 + if r.URL.Path != "/export" { 81 + t.Errorf("unexpected path: %s", r.URL.Path) 82 + } 83 + 84 + // Check query parameters 85 + count := r.URL.Query().Get("count") 86 + if count != "100" { 87 + t.Errorf("unexpected count: %s", count) 88 + } 89 + 90 + // Return mock JSONL data 91 + w.Header().Set("Content-Type", "application/x-ndjson") 92 + for i := 0; i < 10; i++ { 93 + op := plc.PLCOperation{ 94 + DID: "did:plc:test" + string(rune(i)), 95 + CID: "bafytest" + string(rune(i)), 96 + CreatedAt: time.Now(), 97 + Operation: map[string]interface{}{"type": "create"}, 98 + } 99 + json.NewEncoder(w).Encode(op) 100 + } 101 + })) 102 + defer server.Close() 103 + 104 + // Create client 105 + client := plc.NewClient(server.URL) 106 + defer client.Close() 107 + 108 + // Test export 109 + ctx := context.Background() 110 + ops, err := client.Export(ctx, plc.ExportOptions{ 111 + Count: 100, 112 + }) 113 + if err != nil { 114 + t.Fatalf("Export failed: %v", err) 115 + } 116 + 117 + if len(ops) != 10 { 118 + t.Errorf("expected 10 operations, got %d", len(ops)) 119 + } 120 + 121 + // Check that RawJSON is preserved 122 + if len(ops[0].RawJSON) == 0 { 123 + t.Error("RawJSON not preserved") 124 + } 125 + }) 126 + 127 + t.Run("RateLimitRetry", func(t *testing.T) { 128 + attempts := 0 129 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 130 + attempts++ 131 + if attempts < 2 { 132 + // Return 429 on first attempt 133 + w.Header().Set("Retry-After", "1") 134 + w.WriteHeader(http.StatusTooManyRequests) 135 + return 136 + } 137 + // Success on second attempt 138 + w.Header().Set("Content-Type", "application/x-ndjson") 139 + op := plc.PLCOperation{DID: "did:plc:test", CID: "bafytest", CreatedAt: time.Now()} 140 + json.NewEncoder(w).Encode(op) 141 + })) 142 + defer server.Close() 143 + 144 + client := plc.NewClient(server.URL) 145 + defer client.Close() 146 + 147 + ctx := context.Background() 148 + ops, err := client.Export(ctx, plc.ExportOptions{Count: 1}) 149 + if err != nil { 150 + t.Fatalf("Export failed after retry: %v", err) 151 + } 152 + 153 + if len(ops) != 1 { 154 + t.Errorf("expected 1 operation, got %d", len(ops)) 155 + } 156 + 157 + if attempts < 2 { 158 + t.Errorf("expected at least 2 attempts, got %d", attempts) 159 + } 160 + }) 161 + 162 + t.Run("GetDID", func(t *testing.T) { 163 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 164 + if r.URL.Path != "/did:plc:test123" { 165 + t.Errorf("unexpected path: %s", r.URL.Path) 166 + } 167 + 168 + doc := plc.DIDDocument{ 169 + Context: []string{"https://www.w3.org/ns/did/v1"}, 170 + ID: "did:plc:test123", 171 + } 172 + json.NewEncoder(w).Encode(doc) 173 + })) 174 + defer server.Close() 175 + 176 + client := plc.NewClient(server.URL) 177 + defer client.Close() 178 + 179 + ctx := context.Background() 180 + doc, err := client.GetDID(ctx, "did:plc:test123") 181 + if err != nil { 182 + t.Fatalf("GetDID failed: %v", err) 183 + } 184 + 185 + if doc.ID != "did:plc:test123" { 186 + t.Errorf("unexpected DID: %s", doc.ID) 187 + } 188 + }) 189 + } 190 + 191 + // TestRateLimiter tests rate limiting functionality 192 + func TestRateLimiter(t *testing.T) { 193 + t.Run("BasicRateLimit", func(t *testing.T) { 194 + // 10 requests per second 195 + rl := plc.NewRateLimiter(10, time.Second) 196 + defer rl.Stop() 197 + 198 + ctx := context.Background() 199 + 200 + // First 10 should be fast 201 + start := time.Now() 202 + for i := 0; i < 10; i++ { 203 + if err := rl.Wait(ctx); err != nil { 204 + t.Fatalf("Wait failed: %v", err) 205 + } 206 + } 207 + elapsed := time.Since(start) 208 + 209 + // Should be very fast (less than 100ms) 210 + if elapsed > 100*time.Millisecond { 211 + t.Errorf("expected fast execution, took %v", elapsed) 212 + } 213 + }) 214 + 215 + t.Run("ContextCancellation", func(t *testing.T) { 216 + rl := plc.NewRateLimiter(1, time.Minute) // Very slow rate 217 + defer rl.Stop() 218 + 219 + // Consume the one available token 220 + ctx := context.Background() 221 + rl.Wait(ctx) 222 + 223 + // Try to wait with cancelled context 224 + cancelCtx, cancel := context.WithCancel(context.Background()) 225 + cancel() // Cancel immediately 226 + 227 + err := rl.Wait(cancelCtx) 228 + if err != context.Canceled { 229 + t.Errorf("expected context.Canceled, got %v", err) 230 + } 231 + }) 232 + } 233 + 234 + // Benchmark tests 235 + func BenchmarkSerializeJSONL(b *testing.B) { 236 + ops := make([]plc.PLCOperation, 10000) 237 + for i := 0; i < 10000; i++ { 238 + ops[i] = plc.PLCOperation{ 239 + DID: "did:plc:test", 240 + CID: "bafytest", 241 + CreatedAt: time.Now(), 242 + Operation: map[string]interface{}{"type": "create"}, 243 + } 244 + } 245 + 246 + logger := &benchLogger{} 247 + operations, _ := bundle.NewOperations(bundle.CompressionBetter, logger) 248 + defer operations.Close() 249 + 250 + b.ResetTimer() 251 + for i := 0; i < b.N; i++ { 252 + _ = operations.SerializeJSONL(ops) 253 + } 254 + } 255 + 256 + type benchLogger struct{} 257 + 258 + func (l *benchLogger) Printf(format string, v ...interface{}) {} 259 + func (l *benchLogger) Println(v ...interface{}) {}
+7
plc_bundles.json
··· 1 + { 2 + "version": "1.0", 3 + "last_bundle": 0, 4 + "updated_at": "2025-10-28T02:44:11.775384Z", 5 + "total_size_bytes": 0, 6 + "bundles": [] 7 + }