diff --git a/src/tests/csv.test.ts b/src/tests/csv.test.ts new file mode 100644 index 0000000..c65e012 --- /dev/null +++ b/src/tests/csv.test.ts @@ -0,0 +1,286 @@ +/** + * Unit tests for CSV parsing and Last.fm record conversion + * + * Tests cover: + * - CSV delimiter detection + * - Column normalization + * - Record parsing and validation + * - Timestamp handling + * - Last.fm record conversion + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { + parseLastFmCsv, + convertToPlayRecord, +} from '../lib/csv.js'; +import type { Config, LastFmCsvRecord } from '../types.js'; + +const mockConfig: Config = { + RECORD_TYPE: 'fm.teal.alpha.feed.play', + MIN_RECORDS_FOR_SCALING: 20, + BASE_BATCH_SIZE: 200, + MAX_BATCH_SIZE: 200, + SCALING_FACTOR: 1.5, + DEFAULT_BATCH_SIZE: 100, + DEFAULT_BATCH_DELAY: 2000, + MIN_BATCH_DELAY: 1000, + RECORDS_PER_DAY_LIMIT: 10000, + SAFETY_MARGIN: 0.75, + AGGRESSIVE_SAFETY_MARGIN: 0.85, + SLINGSHOT_RESOLVER: 'https://slingshot.microcosm.blue', +}; + +describe('CSV Parsing', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'csv-test-')); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('should parse valid CSV with comma delimiter', () => { + const csvContent = `uts,utc_time,artist,album,track +1623801600,2021-06-15T20:00:00Z,Test Artist,Test Album,Test Track`; + + const filePath = path.join(tempDir, 'test.csv'); + fs.writeFileSync(filePath, csvContent); + + const records = parseLastFmCsv(filePath); + assert.strictEqual(records.length, 1); + assert.strictEqual(records[0].artist, 'Test Artist'); + assert.strictEqual(records[0].track, 'Test Track'); + assert.strictEqual(records[0].album, 'Test Album'); + }); + + it('should detect semicolon delimiter', () => { + const csvContent = `uts;utc_time;artist;album;track +1623801600;2021-06-15T20:00:00Z;Artist A;Album A;Track A`; + + const filePath = path.join(tempDir, 'test.csv'); + fs.writeFileSync(filePath, csvContent); + + const records = parseLastFmCsv(filePath); + assert.strictEqual(records.length, 1); + assert.strictEqual(records[0].artist, 'Artist A'); + }); + + it('should handle tab delimiter', () => { + const csvContent = `uts\tutc_time\tartist\talbum\ttrack +1623801600\t2021-06-15T20:00:00Z\tArtist B\tAlbum B\tTrack B`; + + const filePath = path.join(tempDir, 'test.csv'); + fs.writeFileSync(filePath, csvContent); + + const records = parseLastFmCsv(filePath); + assert.strictEqual(records.length, 1); + assert.strictEqual(records[0].artist, 'Artist B'); + }); + + it('should remove BOM if present', () => { + const csvContent = `uts,utc_time,artist,album,track +1623801600,2021-06-15T20:00:00Z,Artist C,Album C,Track C`; + + const filePath = path.join(tempDir, 'test.csv'); + // Write with BOM + fs.writeFileSync(filePath, '\ufeff' + csvContent); + + const records = parseLastFmCsv(filePath); + assert.strictEqual(records.length, 1); + assert.strictEqual(records[0].artist, 'Artist C'); + }); + + it('should convert millisecond timestamps to seconds', () => { + const csvContent = `uts,utc_time,artist,album,track +1623801600000,2021-06-15T20:00:00Z,Artist D,Album D,Track D`; + + const filePath = path.join(tempDir, 'test.csv'); + fs.writeFileSync(filePath, csvContent); + + const records = parseLastFmCsv(filePath); + assert.strictEqual(records.length, 1); + assert.ok(records[0].uts); + // Should be converted from milliseconds to seconds + const timestamp = parseInt(records[0].uts); + assert.ok(timestamp < 2000000000); // Should be in seconds, not milliseconds + }); + + it('should validate required fields', () => { + const csvContent = `uts,utc_time,artist,album,track +1623801600,2021-06-15T20:00:00Z,Artist E,,Track E +,2021-06-15T20:00:00Z,,Album F,Track F`; + + const filePath = path.join(tempDir, 'test.csv'); + fs.writeFileSync(filePath, csvContent); + + const records = parseLastFmCsv(filePath); + // Should only have records with all required fields + assert.strictEqual( + records.length, + 1, + 'Should filter out records with missing required fields' + ); + }); + + it('should handle alternative column names', () => { + const csvContent = `timestamp,artist_name,song,album_name +1623801600,Artist G,Track G,Album G`; + + const filePath = path.join(tempDir, 'test.csv'); + fs.writeFileSync(filePath, csvContent); + + const records = parseLastFmCsv(filePath); + assert.strictEqual(records.length, 1); + assert.strictEqual(records[0].artist, 'Artist G'); + assert.strictEqual(records[0].track, 'Track G'); + }); + + it('should handle multiple records', () => { + const csvContent = `uts,utc_time,artist,album,track +1623801600,2021-06-15T20:00:00Z,Artist H,Album H,Track H +1623801700,2021-06-15T20:01:40Z,Artist I,Album I,Track I +1623801800,2021-06-15T20:03:20Z,Artist J,Album J,Track J`; + + const filePath = path.join(tempDir, 'test.csv'); + fs.writeFileSync(filePath, csvContent); + + const records = parseLastFmCsv(filePath); + assert.strictEqual(records.length, 3); + assert.strictEqual(records[0].artist, 'Artist H'); + assert.strictEqual(records[1].artist, 'Artist I'); + assert.strictEqual(records[2].artist, 'Artist J'); + }); +}); + +describe('Last.fm Record Conversion', () => { + it('should convert CSV record to PlayRecord', () => { + const csvRecord: LastFmCsvRecord = { + uts: '1623801600', + utc_time: '2021-06-15T20:00:00Z', + artist: 'Test Artist', + artist_mbid: 'artist-id-123', + album: 'Test Album', + album_mbid: 'album-id-456', + track: 'Test Track', + track_mbid: 'track-id-789', + }; + + const playRecord = convertToPlayRecord(csvRecord, mockConfig); + + assert.strictEqual(playRecord.trackName, 'Test Track'); + assert.strictEqual(playRecord.artists[0].artistName, 'Test Artist'); + assert.strictEqual(playRecord.artists[0].artistMbId, 'artist-id-123'); + assert.strictEqual(playRecord.releaseName, 'Test Album'); + assert.strictEqual(playRecord.releaseMbId, 'album-id-456'); + assert.strictEqual(playRecord.recordingMbId, 'track-id-789'); + assert.strictEqual(playRecord.musicServiceBaseDomain, 'last.fm'); + assert.match(playRecord.originUrl, /last\.fm/); + }); + + it('should handle records without optional MBIDs', () => { + const csvRecord: LastFmCsvRecord = { + uts: '1623801600', + utc_time: '2021-06-15T20:00:00Z', + artist: 'Test Artist', + album: 'Test Album', + track: 'Test Track', + }; + + const playRecord = convertToPlayRecord(csvRecord, mockConfig); + + assert.strictEqual(playRecord.trackName, 'Test Track'); + assert.strictEqual(playRecord.artists[0].artistName, 'Test Artist'); + assert.ok(!playRecord.artists[0].artistMbId); + assert.strictEqual(playRecord.releaseName, 'Test Album'); + assert.ok(!playRecord.releaseMbId); + assert.ok(!playRecord.recordingMbId); + }); + + it('should generate Last.fm origin URL', () => { + const csvRecord: LastFmCsvRecord = { + uts: '1623801600', + utc_time: '2021-06-15T20:00:00Z', + artist: 'The Beatles', + album: 'Abbey Road', + track: 'Come Together', + }; + + const playRecord = convertToPlayRecord(csvRecord, mockConfig); + + assert.match(playRecord.originUrl, /https:\/\/www\.last\.fm/); + assert.match(playRecord.originUrl, /The%20Beatles/); + assert.match(playRecord.originUrl, /Come%20Together/); + }); + + it('should set correct record type', () => { + const csvRecord: LastFmCsvRecord = { + uts: '1623801600', + utc_time: '2021-06-15T20:00:00Z', + artist: 'Artist', + album: 'Album', + track: 'Track', + }; + + const playRecord = convertToPlayRecord(csvRecord, mockConfig); + + assert.strictEqual(playRecord.$type, mockConfig.RECORD_TYPE); + }); + + it('should set submission client agent', () => { + const csvRecord: LastFmCsvRecord = { + uts: '1623801600', + utc_time: '2021-06-15T20:00:00Z', + artist: 'Artist', + album: 'Album', + track: 'Track', + }; + + const playRecord = convertToPlayRecord(csvRecord, mockConfig); + + assert.ok(playRecord.submissionClientAgent); + assert.match(playRecord.submissionClientAgent, /malachite/i); + }); + + it('should parse timestamp correctly', () => { + const csvRecord: LastFmCsvRecord = { + uts: '1623801600', + utc_time: '2021-06-15T20:00:00Z', + artist: 'Artist', + album: 'Album', + track: 'Track', + }; + + const playRecord = convertToPlayRecord(csvRecord, mockConfig); + const playedDate = new Date(playRecord.playedTime); + + assert.strictEqual(playedDate.getTime(), 1623801600000); + }); + + it('should handle empty strings for optional fields', () => { + const csvRecord: LastFmCsvRecord = { + uts: '1623801600', + utc_time: '2021-06-15T20:00:00Z', + artist: 'Artist', + artist_mbid: '', + album: ' ', + album_mbid: '', + track: 'Track', + track_mbid: ' ', + }; + + const playRecord = convertToPlayRecord(csvRecord, mockConfig); + + assert.strictEqual(playRecord.trackName, 'Track'); + assert.ok(!playRecord.releaseName || playRecord.releaseName.trim() === ''); + assert.ok(!playRecord.releaseMbId); + }); +}); diff --git a/src/tests/helpers.test.ts b/src/tests/helpers.test.ts new file mode 100644 index 0000000..6c90560 --- /dev/null +++ b/src/tests/helpers.test.ts @@ -0,0 +1,239 @@ +/** + * Unit tests for helper utilities + * + * Tests cover: + * - Date formatting and localization + * - String normalization + * - Duration formatting + * - Batch size calculation + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { + formatDate, + formatDateRange, + formatDuration, + sortRecords, + normalizeString, + calculateOptimalBatchSize, +} from '../utils/helpers.js'; +import type { PlayRecord, Config } from '../types.js'; + +describe('Date Formatting', () => { + it('should format date without time', () => { + const date = new Date('2025-06-15T10:30:00Z'); + const formatted = formatDate(date, false); + // Should contain month abbreviation, day, and year + assert.ok(formatted.includes('Jun') || formatted.includes('2025')); + assert.ok(formatted.includes('15')); + }); + + it('should format date with time', () => { + const date = new Date('2025-06-15T10:30:00Z'); + const formatted = formatDate(date, true); + // Should contain time information + assert.ok(formatted.includes(':')); + assert.ok(formatted.includes('15')); + }); + + it('should handle string date input', () => { + const formatted = formatDate('2025-06-15T10:30:00Z', false); + assert.ok(formatted); + assert.ok(formatted.includes('15')); + }); + + it('should format date ranges', () => { + const start = new Date('2025-06-01T00:00:00Z'); + const end = new Date('2025-06-30T23:59:59Z'); + const range = formatDateRange(start, end); + assert.match(range, /to/); + assert.ok(range.length > 0); + }); +}); + +describe('String Normalization', () => { + it('should normalize strings to lowercase', () => { + const result = normalizeString('Hello World'); + assert.strictEqual(result, 'hello world'); + }); + + it('should remove punctuation', () => { + const result = normalizeString("it's a test!"); + assert.strictEqual(result, 'its a test'); + }); + + it('should normalize whitespace', () => { + const result = normalizeString('hello world'); + assert.strictEqual(result, 'hello world'); + }); + + it('should handle mixed case and punctuation', () => { + const result = normalizeString("Böhse-Blöffen's Test!!!"); + assert.ok(!result.includes('!')); + assert.strictEqual(result, result.toLowerCase()); + }); + + it('should trim whitespace', () => { + const result = normalizeString(' hello world '); + assert.strictEqual(result, 'hello world'); + }); +}); + +describe('Duration Formatting', () => { + it('should format seconds', () => { + const result = formatDuration(5000); + assert.match(result, /\d+s/); + }); + + it('should format minutes and seconds', () => { + const result = formatDuration(90000); // 1m 30s + assert.match(result, /\d+m\s\d+s/); + }); + + it('should format hours, minutes and seconds', () => { + const result = formatDuration(3665000); // 1h 1m 5s + assert.match(result, /\d+h\s\d+m/); + }); + + it('should handle zero duration', () => { + const result = formatDuration(0); + assert.strictEqual(result, '0s'); + }); + + it('should handle large durations', () => { + const result = formatDuration(86400000); // 24 hours + assert.match(result, /\d+h/); + }); +}); + +describe('Record Sorting', () => { + it('should sort records oldest first by default', () => { + const records: PlayRecord[] = [ + { + $type: 'test', + trackName: 'Track 1', + artists: [{ artistName: 'Artist 1' }], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }, + { + $type: 'test', + trackName: 'Track 2', + artists: [{ artistName: 'Artist 2' }], + playedTime: '2025-06-10T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }, + ]; + + const sorted = sortRecords([...records], false); + assert.strictEqual( + sorted[0].playedTime, + '2025-06-10T10:00:00Z', + 'Should sort oldest first' + ); + assert.strictEqual( + sorted[1].playedTime, + '2025-06-15T10:00:00Z', + 'Should sort newest last' + ); + }); + + it('should sort records newest first when reverse is true', () => { + const records: PlayRecord[] = [ + { + $type: 'test', + trackName: 'Track 1', + artists: [{ artistName: 'Artist 1' }], + playedTime: '2025-06-10T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }, + { + $type: 'test', + trackName: 'Track 2', + artists: [{ artistName: 'Artist 2' }], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }, + ]; + + const sorted = sortRecords([...records], true); + assert.strictEqual( + sorted[0].playedTime, + '2025-06-15T10:00:00Z', + 'Should sort newest first' + ); + assert.strictEqual( + sorted[1].playedTime, + '2025-06-10T10:00:00Z', + 'Should sort oldest last' + ); + }); + + it('should handle empty record array', () => { + const sorted = sortRecords([], false); + assert.deepStrictEqual(sorted, []); + }); + + it('should handle single record', () => { + const record: PlayRecord = { + $type: 'test', + trackName: 'Track 1', + artists: [{ artistName: 'Artist 1' }], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }; + + const sorted = sortRecords([record], false); + assert.strictEqual(sorted.length, 1); + assert.strictEqual(sorted[0].trackName, 'Track 1'); + }); +}); + +describe('Batch Size Calculation', () => { + const mockConfig: Config = { + RECORD_TYPE: 'test', + MIN_RECORDS_FOR_SCALING: 20, + BASE_BATCH_SIZE: 200, + MAX_BATCH_SIZE: 200, + SCALING_FACTOR: 1.5, + DEFAULT_BATCH_SIZE: 100, + DEFAULT_BATCH_DELAY: 2000, + MIN_BATCH_DELAY: 1000, + RECORDS_PER_DAY_LIMIT: 10000, + SAFETY_MARGIN: 0.75, + AGGRESSIVE_SAFETY_MARGIN: 0.85, + SLINGSHOT_RESOLVER: 'https://slingshot.microcosm.blue', + }; + + it('should scale up for larger datasets', () => { + const size = calculateOptimalBatchSize(1000, 2000, mockConfig); + assert.ok(size > 3); + }); + + it('should respect maximum batch size', () => { + const size = calculateOptimalBatchSize(1000000, 2000, mockConfig); + assert.ok(size <= mockConfig.MAX_BATCH_SIZE); + }); + + it('should ensure minimum batch size', () => { + const size = calculateOptimalBatchSize(1, 2000, mockConfig); + assert.ok(size >= 3); + }); + + it('should adjust for low batch delay', () => { + const lowDelay = calculateOptimalBatchSize(500, 1000, mockConfig); + // Low delay might not affect much, but should be valid + assert.ok(lowDelay >= 3); + }); +}); diff --git a/src/tests/input.test.ts b/src/tests/input.test.ts new file mode 100644 index 0000000..898da2e --- /dev/null +++ b/src/tests/input.test.ts @@ -0,0 +1,195 @@ +/** + * Unit tests for input utilities + * + * Tests cover: + * - Quote stripping + * - Menu selection + * - User confirmations + * - Input validation + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert'; + +// Test utility functions that can be tested without I/O +describe('Input Utilities', () => { + describe('Quote stripping helper', () => { + it('should strip single quotes', () => { + const stripQuotes = (str: string): string => { + str = str.trim(); + if ( + (str.startsWith("'") && str.endsWith("'")) || + (str.startsWith('"') && str.endsWith('"')) + ) { + return str.slice(1, -1); + } + return str; + }; + + assert.strictEqual(stripQuotes("'hello'"), 'hello'); + assert.strictEqual(stripQuotes('"hello"'), 'hello'); + assert.strictEqual(stripQuotes("'hello"), "'hello"); + assert.strictEqual(stripQuotes('hello'), 'hello'); + }); + + it('should strip double quotes', () => { + const stripQuotes = (str: string): string => { + str = str.trim(); + if ( + (str.startsWith("'") && str.endsWith("'")) || + (str.startsWith('"') && str.endsWith('"')) + ) { + return str.slice(1, -1); + } + return str; + }; + + assert.strictEqual(stripQuotes('"test path"'), 'test path'); + assert.strictEqual(stripQuotes("'test path'"), 'test path'); + }); + + it('should handle whitespace around quotes', () => { + const stripQuotes = (str: string): string => { + str = str.trim(); + if ( + (str.startsWith("'") && str.endsWith("'")) || + (str.startsWith('"') && str.endsWith('"')) + ) { + return str.slice(1, -1); + } + return str; + }; + + assert.strictEqual(stripQuotes(" 'hello' "), 'hello'); + assert.strictEqual(stripQuotes(' "hello" '), 'hello'); + }); + + it('should not strip mismatched quotes', () => { + const stripQuotes = (str: string): string => { + str = str.trim(); + if ( + (str.startsWith("'") && str.endsWith("'")) || + (str.startsWith('"') && str.endsWith('"')) + ) { + return str.slice(1, -1); + } + return str; + }; + + assert.strictEqual(stripQuotes('"hello\''), '"hello\''); + assert.strictEqual(stripQuotes("'hello\""), "'hello\""); + }); + }); + + describe('File path validation', () => { + it('should validate file path format', () => { + const isValidPath = (filePath: string): boolean => { + return !!(filePath && filePath.trim().length > 0); + }; + + assert.strictEqual(isValidPath('/path/to/file.csv'), true); + assert.strictEqual(isValidPath('./relative/path.csv'), true); + assert.strictEqual(isValidPath('~/home/file.csv'), true); + assert.strictEqual(isValidPath(''), false); + assert.strictEqual(isValidPath(' '), false); + }); + + it('should handle paths with spaces', () => { + const isValidPath = (filePath: string): boolean => { + return !!(filePath && filePath.trim().length > 0); + }; + + assert.strictEqual(isValidPath('/path/to/my file.csv'), true); + assert.strictEqual(isValidPath('C:\\\\Users\\\\My User\\\\file.csv'), true); + }); + }); + + describe('Handle validation', () => { + it('should validate AT Protocol handle format', () => { + const isValidHandle = (handle: string): boolean => { + // Basic validation - should have .bsky.social or be a DID + return ( + handle.includes('.') || + handle.startsWith('did:') || + handle.startsWith('did:key') + ); + }; + + assert.strictEqual(isValidHandle('user.bsky.social'), true); + assert.strictEqual(isValidHandle('myhandle.bsky.social'), true); + assert.strictEqual(isValidHandle('did:key:z6MkhaXgBZDvotzL'), true); + }); + + it('should reject invalid handles', () => { + const isValidHandle = (handle: string): boolean => { + return ( + handle.includes('.') || + handle.startsWith('did:') || + handle.startsWith('did:key') + ); + }; + + assert.strictEqual(isValidHandle('user'), false); + assert.strictEqual(isValidHandle(''), false); + }); + }); + + describe('Password validation', () => { + it('should accept non-empty passwords', () => { + const isValidPassword = (password: string): boolean => { + return !!(password && password.length > 0); + }; + + assert.ok(isValidPassword('mypassword')); + assert.ok(isValidPassword('app-password-xyz')); + assert.ok(!isValidPassword('')); + }); + }); + + describe('Mode validation', () => { + it('should validate import modes', () => { + const VALID_MODES = ['lastfm', 'spotify', 'combined', 'sync', 'deduplicate']; + + const isValidMode = (mode: string): boolean => { + return VALID_MODES.includes(mode.toLowerCase()); + }; + + assert.ok(isValidMode('lastfm')); + assert.ok(isValidMode('spotify')); + assert.ok(isValidMode('combined')); + assert.ok(isValidMode('sync')); + assert.ok(isValidMode('deduplicate')); + assert.ok(!isValidMode('invalid')); + assert.ok(!isValidMode('')); + }); + }); + + describe('Batch size validation', () => { + it('should validate batch size range', () => { + const isValidBatchSize = (size: number): boolean => { + return size >= 1 && size <= 200; + }; + + assert.ok(isValidBatchSize(1)); + assert.ok(isValidBatchSize(100)); + assert.ok(isValidBatchSize(200)); + assert.ok(!isValidBatchSize(0)); + assert.ok(!isValidBatchSize(201)); + assert.ok(!isValidBatchSize(-1)); + }); + }); + + describe('Batch delay validation', () => { + it('should validate batch delay in milliseconds', () => { + const isValidBatchDelay = (delay: number): boolean => { + return delay >= 1000 && delay <= 60000; + }; + + assert.ok(isValidBatchDelay(1000)); + assert.ok(isValidBatchDelay(2000)); + assert.ok(isValidBatchDelay(60000)); + assert.ok(!isValidBatchDelay(500)); + assert.ok(!isValidBatchDelay(61000)); + }); + }); +}); diff --git a/src/tests/spotify.test.ts b/src/tests/spotify.test.ts new file mode 100644 index 0000000..7dc0d2e --- /dev/null +++ b/src/tests/spotify.test.ts @@ -0,0 +1,291 @@ +/** + * Unit tests for Spotify parsing and record conversion + * + * Tests cover: + * - JSON parsing (single file and directories) + * - Record filtering + * - Spotify record conversion + * - Track metadata extraction + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { + parseSpotifyJson, + convertSpotifyToPlayRecord, +} from '../lib/spotify.js'; +import type { Config } from '../types.js'; + +const mockConfig: Config = { + RECORD_TYPE: 'fm.teal.alpha.feed.play', + MIN_RECORDS_FOR_SCALING: 20, + BASE_BATCH_SIZE: 200, + MAX_BATCH_SIZE: 200, + SCALING_FACTOR: 1.5, + DEFAULT_BATCH_SIZE: 100, + DEFAULT_BATCH_DELAY: 2000, + MIN_BATCH_DELAY: 1000, + RECORDS_PER_DAY_LIMIT: 10000, + SAFETY_MARGIN: 0.75, + AGGRESSIVE_SAFETY_MARGIN: 0.85, + SLINGSHOT_RESOLVER: 'https://slingshot.microcosm.blue', +}; + +// Helper function to create a valid Spotify record +function createSpotifyRecord(overrides: any = {}) { + return { + ts: '2021-06-15T20:00:00Z', + platform: 'web', + ms_played: 180000, + conn_country: 'US', + master_metadata_track_name: 'Track', + master_metadata_album_artist_name: 'Artist', + master_metadata_album_album_name: null, + spotify_track_uri: null, + episode_name: null, + episode_show_name: null, + spotify_episode_uri: null, + reason_start: 'uriopen' as const, + reason_end: 'endplay' as const, + shuffle: false, + skipped: false, + offline: false, + offline_timestamp: null, + incognito_mode: false, + ...overrides, + }; +} + +describe('Spotify JSON Parsing', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spotify-test-')); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('should parse single Spotify JSON file', () => { + const spotifyData = [ + createSpotifyRecord({ + master_metadata_track_name: 'Track One', + master_metadata_album_artist_name: 'Artist One', + master_metadata_album_album_name: 'Album One', + spotify_track_uri: 'spotify:track:123', + }), + ]; + + const filePath = path.join(tempDir, 'Streaming_History_Audio_0.json'); + fs.writeFileSync(filePath, JSON.stringify(spotifyData)); + + const records = parseSpotifyJson(filePath); + assert.strictEqual(records.length, 1); + assert.strictEqual(records[0].master_metadata_track_name, 'Track One'); + assert.strictEqual( + records[0].master_metadata_album_artist_name, + 'Artist One' + ); + }); + + it('should parse directory with multiple Spotify JSON files', () => { + const spotifyData1 = [ + createSpotifyRecord({ + master_metadata_track_name: 'Track One', + master_metadata_album_artist_name: 'Artist One', + master_metadata_album_album_name: 'Album One', + spotify_track_uri: 'spotify:track:123', + }), + ]; + + const spotifyData2 = [ + createSpotifyRecord({ + ts: '2021-06-14T20:00:00Z', + platform: 'mobile', + ms_played: 200000, + master_metadata_track_name: 'Track Two', + master_metadata_album_artist_name: 'Artist Two', + master_metadata_album_album_name: 'Album Two', + spotify_track_uri: 'spotify:track:456', + }), + ]; + + fs.writeFileSync( + path.join(tempDir, 'Streaming_History_Audio_0.json'), + JSON.stringify(spotifyData1) + ); + fs.writeFileSync( + path.join(tempDir, 'Streaming_History_Audio_1.json'), + JSON.stringify(spotifyData2) + ); + + const records = parseSpotifyJson(tempDir); + assert.strictEqual(records.length, 2); + assert.strictEqual(records[0].master_metadata_track_name, 'Track One'); + assert.strictEqual(records[1].master_metadata_track_name, 'Track Two'); + }); + + it('should filter out non-music records (podcasts, audiobooks)', () => { + const spotifyData = [ + createSpotifyRecord({ + master_metadata_track_name: 'Music Track', + master_metadata_album_artist_name: 'Artist', + master_metadata_album_album_name: 'Album', + spotify_track_uri: 'spotify:track:123', + }), + createSpotifyRecord({ + ts: '2021-06-15T19:00:00Z', + master_metadata_track_name: null, + master_metadata_album_artist_name: null, + episode_name: 'Podcast Episode', + episode_show_name: 'Podcast Show', + spotify_episode_uri: 'spotify:episode:123', + }), + ]; + + const filePath = path.join(tempDir, 'Streaming_History_Audio_0.json'); + fs.writeFileSync(filePath, JSON.stringify(spotifyData)); + + const records = parseSpotifyJson(filePath); + assert.strictEqual( + records.length, + 1, + 'Should filter out podcast/audiobook records' + ); + assert.strictEqual(records[0].master_metadata_track_name, 'Music Track'); + }); + + it('should handle empty files', () => { + const filePath = path.join(tempDir, 'Streaming_History_Audio_0.json'); + fs.writeFileSync(filePath, JSON.stringify([])); + + const records = parseSpotifyJson(filePath); + assert.strictEqual(records.length, 0); + }); + + it('should ignore non-streaming history files', () => { + const filePath1 = path.join(tempDir, 'Streaming_History_Audio_0.json'); + const filePath2 = path.join(tempDir, 'other_file.json'); + + fs.writeFileSync( + filePath1, + JSON.stringify([ + createSpotifyRecord({ + master_metadata_track_name: 'Track', + master_metadata_album_artist_name: 'Artist', + spotify_track_uri: 'spotify:track:123', + }), + ]) + ); + fs.writeFileSync(filePath2, JSON.stringify([])); + + const records = parseSpotifyJson(tempDir); + assert.strictEqual(records.length, 1); + }); +}); + +describe('Spotify Record Conversion', () => { + it('should convert Spotify record to PlayRecord', () => { + const spotifyRecord = createSpotifyRecord({ + master_metadata_track_name: 'Test Track', + master_metadata_album_artist_name: 'Test Artist', + master_metadata_album_album_name: 'Test Album', + spotify_track_uri: 'spotify:track:7qiZfU4dY1lsylvNFutmtK', + }); + + const playRecord = convertSpotifyToPlayRecord(spotifyRecord, mockConfig); + + assert.strictEqual(playRecord.trackName, 'Test Track'); + assert.strictEqual(playRecord.artists[0].artistName, 'Test Artist'); + assert.strictEqual(playRecord.releaseName, 'Test Album'); + assert.strictEqual(playRecord.playedTime, '2021-06-15T20:00:00Z'); + assert.strictEqual(playRecord.musicServiceBaseDomain, 'spotify.com'); + assert.match(playRecord.originUrl, /spotify.com/); + assert.match(playRecord.originUrl, /7qiZfU4dY1lsylvNFutmtK/); + }); + + it('should generate Spotify origin URL from track URI', () => { + const spotifyRecord = createSpotifyRecord({ + master_metadata_track_name: 'Track', + master_metadata_album_artist_name: 'Artist', + spotify_track_uri: 'spotify:track:abc123xyz', + }); + + const playRecord = convertSpotifyToPlayRecord(spotifyRecord, mockConfig); + + assert.strictEqual( + playRecord.originUrl, + 'https://open.spotify.com/track/abc123xyz' + ); + }); + + it('should handle records without track URI', () => { + const spotifyRecord = createSpotifyRecord({ + master_metadata_track_name: 'Track', + master_metadata_album_artist_name: 'Artist', + spotify_track_uri: null, + }); + + const playRecord = convertSpotifyToPlayRecord(spotifyRecord, mockConfig); + + assert.strictEqual(playRecord.trackName, 'Track'); + assert.strictEqual(playRecord.originUrl, ''); + }); + + it('should handle missing album name', () => { + const spotifyRecord = createSpotifyRecord({ + master_metadata_track_name: 'Track', + master_metadata_album_artist_name: 'Artist', + master_metadata_album_album_name: null, + spotify_track_uri: 'spotify:track:123', + }); + + const playRecord = convertSpotifyToPlayRecord(spotifyRecord, mockConfig); + + assert.strictEqual(playRecord.trackName, 'Track'); + assert.ok(!playRecord.releaseName); + }); + + it('should handle null track name', () => { + const spotifyRecord = createSpotifyRecord({ + master_metadata_track_name: null, + master_metadata_album_artist_name: 'Artist', + spotify_track_uri: 'spotify:track:123', + }); + + const playRecord = convertSpotifyToPlayRecord(spotifyRecord, mockConfig); + + assert.strictEqual(playRecord.trackName, 'Unknown Track'); + }); + + it('should set correct record type', () => { + const spotifyRecord = createSpotifyRecord({ + master_metadata_track_name: 'Track', + master_metadata_album_artist_name: 'Artist', + spotify_track_uri: 'spotify:track:123', + }); + + const playRecord = convertSpotifyToPlayRecord(spotifyRecord, mockConfig); + + assert.strictEqual(playRecord.$type, mockConfig.RECORD_TYPE); + }); + + it('should set submission client agent', () => { + const spotifyRecord = createSpotifyRecord({ + master_metadata_track_name: 'Track', + master_metadata_album_artist_name: 'Artist', + spotify_track_uri: 'spotify:track:123', + }); + + const playRecord = convertSpotifyToPlayRecord(spotifyRecord, mockConfig); + + assert.ok(playRecord.submissionClientAgent); + assert.match(playRecord.submissionClientAgent, /malachite/i); + }); +}); diff --git a/src/tests/sync.test.ts b/src/tests/sync.test.ts new file mode 100644 index 0000000..88dcf5a --- /dev/null +++ b/src/tests/sync.test.ts @@ -0,0 +1,342 @@ +/** + * Unit tests for sync utilities + * + * Tests cover: + * - Record key generation + * - Record deduplication + * - Duplicate detection + * - Record filtering + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { createRecordKey, deduplicateInputRecords } from '../lib/sync.js'; +import type { PlayRecord } from '../types.js'; + +describe('Record Key Generation', () => { + it('should create consistent key for same record', () => { + const record: PlayRecord = { + $type: 'test', + trackName: 'Test Track', + artists: [{ artistName: 'Test Artist' }], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }; + + const key1 = createRecordKey(record); + const key2 = createRecordKey(record); + + assert.strictEqual(key1, key2); + }); + + it('should create different keys for different records', () => { + const record1: PlayRecord = { + $type: 'test', + trackName: 'Track One', + artists: [{ artistName: 'Artist One' }], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }; + + const record2: PlayRecord = { + $type: 'test', + trackName: 'Track Two', + artists: [{ artistName: 'Artist One' }], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }; + + const key1 = createRecordKey(record1); + const key2 = createRecordKey(record2); + + assert.notStrictEqual(key1, key2); + }); + + it('should normalize case in record key', () => { + const record1: PlayRecord = { + $type: 'test', + trackName: 'Test Track', + artists: [{ artistName: 'Test Artist' }], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }; + + const record2: PlayRecord = { + $type: 'test', + trackName: 'test track', + artists: [{ artistName: 'test artist' }], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }; + + const key1 = createRecordKey(record1); + const key2 = createRecordKey(record2); + + assert.strictEqual(key1, key2); + }); + + it('should normalize whitespace in record key', () => { + const record1: PlayRecord = { + $type: 'test', + trackName: 'Test Track', + artists: [{ artistName: 'Test Artist' }], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }; + + const record2: PlayRecord = { + $type: 'test', + trackName: 'Test Track', + artists: [{ artistName: 'Test Artist' }], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }; + + const key1 = createRecordKey(record1); + const key2 = createRecordKey(record2); + + assert.strictEqual(key1, key2); + }); + + it('should include timestamp in key', () => { + const record1: PlayRecord = { + $type: 'test', + trackName: 'Test Track', + artists: [{ artistName: 'Test Artist' }], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }; + + const record2: PlayRecord = { + $type: 'test', + trackName: 'Test Track', + artists: [{ artistName: 'Test Artist' }], + playedTime: '2025-06-15T11:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }; + + const key1 = createRecordKey(record1); + const key2 = createRecordKey(record2); + + assert.notStrictEqual(key1, key2); + }); + + it('should handle multiple artists', () => { + const record: PlayRecord = { + $type: 'test', + trackName: 'Test Track', + artists: [ + { artistName: 'Artist One' }, + { artistName: 'Artist Two' }, + ], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }; + + const key = createRecordKey(record); + assert.ok(key); + assert.ok(key.includes('artist one')); + }); +}); + +describe('Record Deduplication', () => { + it('should identify duplicate records', () => { + const record1: PlayRecord = { + $type: 'test', + trackName: 'Test Track', + artists: [{ artistName: 'Test Artist' }], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }; + + const record2: PlayRecord = { + $type: 'test', + trackName: 'Test Track', + artists: [{ artistName: 'Test Artist' }], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }; + + const result = deduplicateInputRecords([record1, record2]); + + assert.strictEqual(result.unique.length, 1); + assert.strictEqual(result.duplicates, 1); + }); + + it('should keep first occurrence of duplicates', () => { + const record1: PlayRecord = { + $type: 'test', + trackName: 'Test Track', + artists: [{ artistName: 'Test Artist' }], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'last.fm', + originUrl: 'http://last.fm', + }; + + const record2: PlayRecord = { + $type: 'test', + trackName: 'Test Track', + artists: [{ artistName: 'Test Artist' }], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'spotify.com', + originUrl: 'http://spotify.com', + }; + + const result = deduplicateInputRecords([record1, record2]); + + assert.strictEqual(result.unique.length, 1); + assert.strictEqual(result.unique[0].musicServiceBaseDomain, 'last.fm'); + }); + + it('should not mark different records as duplicates', () => { + const records: PlayRecord[] = [ + { + $type: 'test', + trackName: 'Track One', + artists: [{ artistName: 'Artist One' }], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }, + { + $type: 'test', + trackName: 'Track Two', + artists: [{ artistName: 'Artist Two' }], + playedTime: '2025-06-15T11:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }, + ]; + + const result = deduplicateInputRecords(records); + + assert.strictEqual(result.unique.length, 2); + assert.strictEqual(result.duplicates, 0); + }); + + it('should handle empty record array', () => { + const result = deduplicateInputRecords([]); + + assert.strictEqual(result.unique.length, 0); + assert.strictEqual(result.duplicates, 0); + }); + + it('should handle single record', () => { + const record: PlayRecord = { + $type: 'test', + trackName: 'Test Track', + artists: [{ artistName: 'Test Artist' }], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }; + + const result = deduplicateInputRecords([record]); + + assert.strictEqual(result.unique.length, 1); + assert.strictEqual(result.duplicates, 0); + }); + + it('should detect multiple duplicate sets', () => { + const records: PlayRecord[] = [ + { + $type: 'test', + trackName: 'Track A', + artists: [{ artistName: 'Artist A' }], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }, + { + $type: 'test', + trackName: 'Track A', + artists: [{ artistName: 'Artist A' }], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }, + { + $type: 'test', + trackName: 'Track B', + artists: [{ artistName: 'Artist B' }], + playedTime: '2025-06-15T11:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }, + { + $type: 'test', + trackName: 'Track B', + artists: [{ artistName: 'Artist B' }], + playedTime: '2025-06-15T11:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }, + ]; + + const result = deduplicateInputRecords(records); + + assert.strictEqual(result.unique.length, 2); + assert.strictEqual(result.duplicates, 2); + }); + + it('should normalize track and artist names when deduplicating', () => { + const records: PlayRecord[] = [ + { + $type: 'test', + trackName: 'Test Track', + artists: [{ artistName: 'Test Artist' }], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }, + { + $type: 'test', + trackName: 'test track', + artists: [{ artistName: 'test artist' }], + playedTime: '2025-06-15T10:00:00Z', + submissionClientAgent: 'test', + musicServiceBaseDomain: 'test.com', + originUrl: 'http://test.com', + }, + ]; + + const result = deduplicateInputRecords(records); + + assert.strictEqual(result.unique.length, 1); + assert.strictEqual(result.duplicates, 1); + }); +}); diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 1bfffa0..a954ffa 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -148,6 +148,10 @@ export function normalizeString(str: string): string { * @returns Sorted records */ export function sortRecords(records: PlayRecord[], reverseChronological = false): PlayRecord[] { + if (records.length === 0) { + return records; + } + console.log(`Sorting records ${reverseChronological ? 'newest' : 'oldest'} first...`); records.sort((a, b) => {