like malachite (atproto-lastfm-importer) but in go and bluer
go spotify tealfm lastfm atproto
at main 453 lines 17 kB view raw
1package sync 2 3import ( 4 "testing" 5 "time" 6) 7 8func TestPrepareWrites(t *testing.T) { 9 baseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) 10 11 tests := []struct { 12 name string 13 records []*PlayRecord 14 expectedWrites int 15 expectUnique bool 16 }{ 17 { 18 name: "empty records returns nil", 19 records: []*PlayRecord{}, 20 expectedWrites: 0, 21 }, 22 { 23 name: "single record", 24 records: []*PlayRecord{ 25 { 26 TrackName: "Song A", 27 Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, 28 PlayedTime: Timestamp{Time: baseTime}, 29 }, 30 }, 31 expectedWrites: 1, 32 expectUnique: true, 33 }, 34 { 35 name: "multiple records same timestamp", 36 records: []*PlayRecord{ 37 {TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}, 38 {TrackName: "Song B", Artists: []PlayRecordArtist{{ArtistName: "Artist B"}}, PlayedTime: Timestamp{Time: baseTime}}, 39 {TrackName: "Song C", Artists: []PlayRecordArtist{{ArtistName: "Artist C"}}, PlayedTime: Timestamp{Time: baseTime}}, 40 }, 41 expectedWrites: 3, 42 expectUnique: true, 43 }, 44 { 45 name: "mixed timestamps", 46 records: []*PlayRecord{ 47 {TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}, 48 {TrackName: "Song B", Artists: []PlayRecordArtist{{ArtistName: "Artist B"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Second)}}, 49 {TrackName: "Song C", Artists: []PlayRecordArtist{{ArtistName: "Artist C"}}, PlayedTime: Timestamp{Time: baseTime}}, 50 {TrackName: "Song D", Artists: []PlayRecordArtist{{ArtistName: "Artist D"}}, PlayedTime: Timestamp{Time: baseTime.Add(2 * time.Second)}}, 51 {TrackName: "Song E", Artists: []PlayRecordArtist{{ArtistName: "Artist E"}}, PlayedTime: Timestamp{Time: baseTime}}, 52 }, 53 expectedWrites: 5, 54 expectUnique: true, 55 }, 56 } 57 58 for _, tt := range tests { 59 t.Run(tt.name, func(t *testing.T) { 60 writes, err := prepareWrites(tt.records, RecordType) 61 if err != nil { 62 t.Fatalf("PrepareWrites() error = %v", err) 63 } 64 65 if tt.expectedWrites == 0 { 66 if writes != nil { 67 t.Errorf("PrepareWrites() = %v, want nil", writes) 68 } 69 return 70 } 71 72 if len(writes) != tt.expectedWrites { 73 t.Errorf("len(writes) = %d, want %d", len(writes), tt.expectedWrites) 74 } 75 76 if tt.expectUnique { 77 rkeys := make(map[string]bool) 78 for _, w := range writes { 79 rkey := w["rkey"].(string) 80 if rkeys[rkey] { 81 t.Errorf("duplicate rkey generated: %s", rkey) 82 } 83 rkeys[rkey] = true 84 } 85 if len(rkeys) != len(writes) { 86 t.Errorf("got %d unique rkeys, want %d", len(rkeys), len(writes)) 87 } 88 } 89 }) 90 } 91} 92 93func TestPrepareWritesManyCollisions(t *testing.T) { 94 baseTime := time.Date(2024, 1, 15, 10, 0, 0, 123456789, time.UTC) 95 96 numRecords := 50 97 records := make([]*PlayRecord, numRecords) 98 for i := range numRecords { 99 records[i] = &PlayRecord{ 100 TrackName: "Song", 101 Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, 102 PlayedTime: Timestamp{Time: baseTime}, 103 } 104 } 105 106 writes, err := prepareWrites(records, RecordType) 107 if err != nil { 108 t.Fatalf("prepareWrites() error = %v", err) 109 } 110 111 if len(writes) != numRecords { 112 t.Errorf("len(writes) = %d, want %d", len(writes), numRecords) 113 } 114 115 rkeys := make(map[string]bool) 116 for _, w := range writes { 117 rkey := w["rkey"].(string) 118 if rkeys[rkey] { 119 t.Errorf("duplicate rkey generated: %s", rkey) 120 } 121 rkeys[rkey] = true 122 } 123 124 if len(rkeys) != numRecords { 125 t.Errorf("got %d unique rkeys, want %d", len(rkeys), numRecords) 126 } 127} 128 129func TestFilterNew(t *testing.T) { 130 baseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) 131 processedKey := CreateRecordKey(&PlayRecord{TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}) 132 133 tests := []struct { 134 name string 135 records []*PlayRecord 136 existing []ExistingRecord 137 processed map[string]bool 138 tolerance time.Duration 139 wantNewCount int 140 wantNewTracks []string 141 }{ 142 { 143 name: "excludes exact matches", 144 records: []*PlayRecord{ 145 {TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}, 146 {TrackName: "Song B", Artists: []PlayRecordArtist{{ArtistName: "Artist B"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Minute)}}, 147 {TrackName: "Song C", Artists: []PlayRecordArtist{{ArtistName: "Artist C"}}, PlayedTime: Timestamp{Time: baseTime.Add(2 * time.Minute)}}, 148 }, 149 existing: []ExistingRecord{ 150 {URI: "at://did:example/user/play/abc", Value: &PlayRecord{TrackName: "Song B", Artists: []PlayRecordArtist{{ArtistName: "Artist B"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Minute)}}}, 151 }, 152 tolerance: 5 * time.Minute, 153 wantNewCount: 2, 154 wantNewTracks: []string{"Song A", "Song C"}, 155 }, 156 { 157 name: "returns all when none exist", 158 records: []*PlayRecord{ 159 {TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}, 160 {TrackName: "Song B", Artists: []PlayRecordArtist{{ArtistName: "Artist B"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Minute)}}, 161 }, 162 existing: []ExistingRecord{}, 163 tolerance: 5 * time.Minute, 164 wantNewCount: 2, 165 wantNewTracks: []string{"Song A", "Song B"}, 166 }, 167 { 168 name: "detects duplicates within tolerance", 169 records: []*PlayRecord{ 170 {TrackName: "Same Song", Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(30 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, 171 {TrackName: "Different Song", Artists: []PlayRecordArtist{{ArtistName: "Different Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Hour)}, MusicServiceBaseDomain: MusicServiceSpotify}, 172 }, 173 existing: []ExistingRecord{ 174 {URI: "at://did:example/user/play/abc", Value: &PlayRecord{TrackName: "Same Song", Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}}, 175 }, 176 tolerance: 5 * time.Minute, 177 wantNewCount: 1, 178 wantNewTracks: []string{"Different Song"}, 179 }, 180 { 181 name: "excludes via processed map", 182 records: []*PlayRecord{ 183 {TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}, 184 {TrackName: "Song B", Artists: []PlayRecordArtist{{ArtistName: "Artist B"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Minute)}}, 185 }, 186 existing: []ExistingRecord{}, 187 processed: map[string]bool{ 188 processedKey: true, 189 }, 190 tolerance: 5 * time.Minute, 191 wantNewCount: 1, 192 wantNewTracks: []string{"Song B"}, 193 }, 194 { 195 name: "empty records returns nothing", 196 records: []*PlayRecord{}, 197 existing: []ExistingRecord{{URI: "at://did:example/user/play/abc", Value: &PlayRecord{TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}}}, 198 tolerance: 5 * time.Minute, 199 wantNewCount: 0, 200 wantNewTracks: []string{}, 201 }, 202 { 203 name: "nil processed map works", 204 records: []*PlayRecord{ 205 {TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}, 206 }, 207 existing: []ExistingRecord{}, 208 processed: nil, 209 tolerance: 5 * time.Minute, 210 wantNewCount: 1, 211 wantNewTracks: []string{"Song A"}, 212 }, 213 { 214 name: "nil existing records works", 215 records: []*PlayRecord{ 216 {TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}, 217 }, 218 existing: nil, 219 tolerance: 5 * time.Minute, 220 wantNewCount: 1, 221 wantNewTracks: []string{"Song A"}, 222 }, 223 { 224 name: "zero tolerance requires exact time match", 225 records: []*PlayRecord{ 226 {TrackName: "Same Song", Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, PlayedTime: Timestamp{Time: baseTime}}, 227 {TrackName: "Same Song", Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Second)}}, 228 }, 229 existing: []ExistingRecord{ 230 {URI: "at://did:example/user/play/abc", Value: &PlayRecord{TrackName: "Same Song", Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, PlayedTime: Timestamp{Time: baseTime}}}, 231 }, 232 tolerance: 0, 233 wantNewCount: 1, 234 wantNewTracks: []string{"Same Song"}, 235 }, 236 { 237 name: "matches multiple existing records", 238 records: []*PlayRecord{ 239 {TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}, 240 {TrackName: "Song B", Artists: []PlayRecordArtist{{ArtistName: "Artist B"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Minute)}}, 241 {TrackName: "Song C", Artists: []PlayRecordArtist{{ArtistName: "Artist C"}}, PlayedTime: Timestamp{Time: baseTime.Add(2 * time.Minute)}}, 242 }, 243 existing: []ExistingRecord{ 244 {URI: "at://did:example/user/play/abc", Value: &PlayRecord{TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}}, 245 {URI: "at://did:example/user/play/def", Value: &PlayRecord{TrackName: "Song C", Artists: []PlayRecordArtist{{ArtistName: "Artist C"}}, PlayedTime: Timestamp{Time: baseTime.Add(2 * time.Minute)}}}, 246 }, 247 tolerance: 5 * time.Minute, 248 wantNewCount: 1, 249 wantNewTracks: []string{"Song B"}, 250 }, 251 { 252 name: "time at exact tolerance boundary matches", 253 records: []*PlayRecord{ 254 {TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime.Add(5 * time.Minute)}}, 255 }, 256 existing: []ExistingRecord{ 257 {URI: "at://did:example/user/play/abc", Value: &PlayRecord{TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}}, 258 }, 259 tolerance: 5 * time.Minute, 260 wantNewCount: 0, 261 wantNewTracks: []string{}, 262 }, 263 { 264 name: "time just beyond tolerance does not match", 265 records: []*PlayRecord{ 266 {TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime.Add(5*time.Minute + time.Second)}}, 267 }, 268 existing: []ExistingRecord{ 269 {URI: "at://did:example/user/play/abc", Value: &PlayRecord{TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}}, 270 }, 271 tolerance: 5 * time.Minute, 272 wantNewCount: 1, 273 wantNewTracks: []string{"Song A"}, 274 }, 275 { 276 name: "different artist does not match", 277 records: []*PlayRecord{ 278 {TrackName: "Same Song", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}, 279 }, 280 existing: []ExistingRecord{ 281 {URI: "at://did:example/user/play/abc", Value: &PlayRecord{TrackName: "Same Song", Artists: []PlayRecordArtist{{ArtistName: "Different Artist"}}, PlayedTime: Timestamp{Time: baseTime}}}, 282 }, 283 tolerance: 5 * time.Minute, 284 wantNewCount: 1, 285 wantNewTracks: []string{"Same Song"}, 286 }, 287 { 288 name: "different track does not match", 289 records: []*PlayRecord{ 290 {TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, PlayedTime: Timestamp{Time: baseTime}}, 291 }, 292 existing: []ExistingRecord{ 293 {URI: "at://did:example/user/play/abc", Value: &PlayRecord{TrackName: "Song B", Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, PlayedTime: Timestamp{Time: baseTime}}}, 294 }, 295 tolerance: 5 * time.Minute, 296 wantNewCount: 1, 297 wantNewTracks: []string{"Song A"}, 298 }, 299 { 300 name: "processed takes precedence over existing check", 301 records: []*PlayRecord{ 302 {TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}, 303 }, 304 existing: []ExistingRecord{ 305 {URI: "at://did:example/user/play/abc", Value: &PlayRecord{TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}}, 306 }, 307 processed: map[string]bool{ 308 processedKey: true, 309 }, 310 tolerance: 5 * time.Minute, 311 wantNewCount: 0, 312 wantNewTracks: []string{}, 313 }, 314 { 315 name: "same_record_processed_and_matches_existing_returns_nothing", 316 records: []*PlayRecord{ 317 {TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}, 318 }, 319 existing: []ExistingRecord{ 320 {URI: "at://did:example/user/play/abc", Value: &PlayRecord{TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}}, 321 }, 322 processed: map[string]bool{ 323 processedKey: true, 324 }, 325 tolerance: 5 * time.Minute, 326 wantNewCount: 0, 327 wantNewTracks: []string{}, 328 }, 329 { 330 name: "processed_skips_record_regardless_of_existing", 331 records: []*PlayRecord{ 332 {TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}, 333 {TrackName: "Song B", Artists: []PlayRecordArtist{{ArtistName: "Artist B"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Minute)}}, 334 }, 335 existing: []ExistingRecord{ 336 {URI: "at://did:example/user/play/abc", Value: &PlayRecord{TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}}, 337 }, 338 processed: map[string]bool{ 339 processedKey: true, 340 }, 341 tolerance: 5 * time.Minute, 342 wantNewCount: 1, 343 wantNewTracks: []string{"Song B"}, 344 }, 345 } 346 347 for _, tt := range tests { 348 t.Run(tt.name, func(t *testing.T) { 349 newRecords := FilterNew(tt.records, tt.existing, tt.processed, tt.tolerance) 350 351 if len(newRecords) != tt.wantNewCount { 352 t.Errorf("FilterNew() returned %d records, want %d", len(newRecords), tt.wantNewCount) 353 } 354 355 wantSet := make(map[string]bool) 356 for _, tr := range tt.wantNewTracks { 357 wantSet[tr] = true 358 } 359 for _, rec := range newRecords { 360 if !wantSet[rec.TrackName] { 361 t.Errorf("FilterNew() returned unexpected track %q", rec.TrackName) 362 } 363 } 364 }) 365 } 366} 367 368func TestFindDuplicates(t *testing.T) { 369 tests := []struct { 370 name string 371 records []ExistingRecord 372 expectedDuplicateCount int 373 }{ 374 { 375 name: "finds duplicates", 376 records: []ExistingRecord{ 377 { 378 URI: "at://did:example:user/fm.teal.alpha.feed.play/abc123", 379 CID: "bafyreabc123", 380 Value: &PlayRecord{ 381 TrackName: "Same Song", 382 Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, 383 PlayedTime: Timestamp{Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)}, 384 }, 385 }, 386 { 387 URI: "at://did:example:user/fm.teal.alpha.feed.play/def456", 388 CID: "bafyreedef456", 389 Value: &PlayRecord{ 390 TrackName: "Same Song", 391 Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, 392 PlayedTime: Timestamp{Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)}, 393 }, 394 }, 395 { 396 URI: "at://did:example:user/fm.teal.alpha.feed.play/ghi789", 397 CID: "bafyreghi789", 398 Value: &PlayRecord{ 399 TrackName: "Different Song", 400 Artists: []PlayRecordArtist{{ArtistName: "Different Artist"}}, 401 PlayedTime: Timestamp{Time: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC)}, 402 }, 403 }, 404 }, 405 expectedDuplicateCount: 1, 406 }, 407 { 408 name: "returns empty for no duplicates", 409 records: []ExistingRecord{ 410 { 411 URI: "at://did:example:user/fm.teal.alpha.feed.play/abc123", 412 CID: "bafyreabc123", 413 Value: &PlayRecord{ 414 TrackName: "Song A", 415 Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, 416 PlayedTime: Timestamp{Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)}, 417 }, 418 }, 419 { 420 URI: "at://did:example:user/fm.teal.alpha.feed.play/def456", 421 CID: "bafyreedef456", 422 Value: &PlayRecord{ 423 TrackName: "Song B", 424 Artists: []PlayRecordArtist{{ArtistName: "Artist B"}}, 425 PlayedTime: Timestamp{Time: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC)}, 426 }, 427 }, 428 }, 429 expectedDuplicateCount: 0, 430 }, 431 { 432 name: "handles empty slice", 433 records: []ExistingRecord{}, 434 expectedDuplicateCount: 0, 435 }, 436 } 437 438 for _, tt := range tests { 439 t.Run(tt.name, func(t *testing.T) { 440 duplicates := FindDuplicates(tt.records) 441 442 if len(duplicates) != tt.expectedDuplicateCount { 443 t.Errorf("len(duplicates) = %d, want %d", len(duplicates), tt.expectedDuplicateCount) 444 } 445 446 for key, group := range duplicates { 447 if len(group) < 2 { 448 t.Errorf("group for key %q should have 2+ records, got %d", key, len(group)) 449 } 450 } 451 }) 452 } 453}