like malachite (atproto-lastfm-importer) but in go and bluer
go
spotify
tealfm
lastfm
atproto
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}