diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-18 14:49:06 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-18 14:49:06 +0300 |
| commit | 9cef9b7ea960cb4b314a46cec61984e04ffad07e (patch) | |
| tree | 040d2acec56a8ec5ef72feb8d71f6944dc109af4 /player-android | |
| parent | 9c415d6e449ae1477e39a3b9bfba4265695cd841 (diff) | |
Add Dart unit tests for models and API client constructor
Add fromJson/toJson round-trip tests for all 9 Dart models (Media,
MediaSet, User, Tag, Note, Share, PodcastFeed, PodcastEpisode,
PlaybackHint) and 3 PlayerApiClient constructor tests. Each model
group covers a fully-populated round-trip (including int-to-double
cast verification and ISO-8601 DateTime value comparison) and an
exhaustive defaults-from-empty-map test. All 21 tests run via
'flutter test' with no emulator.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-android')
| -rw-r--r-- | player-android/test/models_test.dart | 554 |
1 files changed, 554 insertions, 0 deletions
diff --git a/player-android/test/models_test.dart b/player-android/test/models_test.dart new file mode 100644 index 0000000..22dc190 --- /dev/null +++ b/player-android/test/models_test.dart @@ -0,0 +1,554 @@ +// Unit tests for all Dart models in player-android/lib/models/. +// +// Each test group covers one model and exercises the fromJson/toJson +// round-trip: a hand-crafted JSON map is decoded via fromJson, and then +// re-encoded via toJson; the resulting map is compared field-by-field to the +// original so that every serialisation key is covered exactly once. +// +// Nullable DateTime fields are verified by comparing the round-tripped +// ISO-8601 string to the original input string, because +// DateTime.parse(s).toIso8601String() is lossless for UTC timestamps. +// Each model is also tested with all fields absent to verify defaults. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:player_android/models/media.dart'; +import 'package:player_android/models/media_set.dart'; +import 'package:player_android/models/user.dart'; +import 'package:player_android/models/tag.dart'; +import 'package:player_android/models/note.dart'; +import 'package:player_android/models/share.dart'; +import 'package:player_android/models/podcast_feed.dart'; +import 'package:player_android/models/podcast_episode.dart'; +import 'package:player_android/models/playback_hint.dart'; +import 'package:player_android/api/player_api_client.dart'; + +void main() { + // --------------------------------------------------------------------------- + // Media + // --------------------------------------------------------------------------- + group('Media', () { + // A fully-populated JSON map used for the round-trip test. + // deleted_at is null to cover the null-timestamp path; created_at is set to + // cover the non-null path. Use an integer for duration to verify that the + // num-to-double cast in fromJson works for int JSON values. + final Map<String, dynamic> fullJson = { + 'id': 1, + 'set_id': 2, + 'rel_path': 'videos/foo.mp4', + 'file_name': 'foo.mp4', + 'abs_path': '/media/videos/foo.mp4', + 'type': 'video', + 'duration': 120, // int in JSON; must be cast to double + 'codec': 'h264', + 'resolution': '1920x1080', + 'bitrate': 4000, + 'file_size_bytes': 512000, + 'width': 1920, + 'height': 1080, + 'thumbnail_path': '/thumbs/foo.jpg', + 'play_count': 3, + 'favorite': true, + 'tags': ['action', 'sci-fi'], + 'deleted_at': null, + 'created_at': '2024-01-15T10:00:00.000Z', + }; + + test('fromJson/toJson round-trip with timestamps', () { + final media = Media.fromJson(fullJson); + + // Verify every field decoded correctly. + expect(media.id, 1); + expect(media.setId, 2); + expect(media.relPath, 'videos/foo.mp4'); + expect(media.fileName, 'foo.mp4'); + expect(media.absPath, '/media/videos/foo.mp4'); + expect(media.type, 'video'); + expect(media.duration, 120.0); // int input becomes double + expect(media.codec, 'h264'); + expect(media.resolution, '1920x1080'); + expect(media.bitrate, 4000); + expect(media.fileSizeBytes, 512000); + expect(media.width, 1920); + expect(media.height, 1080); + expect(media.thumbnailPath, '/thumbs/foo.jpg'); + expect(media.playCount, 3); + expect(media.favorite, isTrue); + expect(media.tags, ['action', 'sci-fi']); + expect(media.deletedAt, isNull); + // Verify the decoded DateTime matches the original ISO-8601 string. + expect(media.createdAt?.toIso8601String(), fullJson['created_at']); + + // Re-encoding must preserve every key present in the original map. + final out = media.toJson(); + expect(out['id'], fullJson['id']); + expect(out['set_id'], fullJson['set_id']); + expect(out['rel_path'], fullJson['rel_path']); + expect(out['file_name'], fullJson['file_name']); + expect(out['abs_path'], fullJson['abs_path']); + expect(out['type'], fullJson['type']); + expect(out['duration'], 120.0); // value comes back as double + expect(out['codec'], fullJson['codec']); + expect(out['resolution'], fullJson['resolution']); + expect(out['bitrate'], fullJson['bitrate']); + expect(out['file_size_bytes'], fullJson['file_size_bytes']); + expect(out['width'], fullJson['width']); + expect(out['height'], fullJson['height']); + expect(out['thumbnail_path'], fullJson['thumbnail_path']); + expect(out['play_count'], fullJson['play_count']); + expect(out['favorite'], fullJson['favorite']); + expect(out['tags'], fullJson['tags']); + expect(out['deleted_at'], isNull); + // The ISO-8601 string must round-trip exactly through DateTime.parse / + // toIso8601String for UTC timestamps. + expect(out['created_at'], fullJson['created_at']); + }); + + test('fromJson applies defaults for all missing fields', () { + final media = Media.fromJson({}); + + expect(media.id, 0); + expect(media.setId, 0); + expect(media.relPath, ''); + expect(media.fileName, ''); + expect(media.absPath, ''); + expect(media.type, ''); + expect(media.duration, 0.0); + expect(media.codec, ''); + expect(media.resolution, ''); + expect(media.bitrate, 0); + expect(media.fileSizeBytes, 0); + expect(media.width, 0); + expect(media.height, 0); + expect(media.thumbnailPath, ''); + expect(media.playCount, 0); + expect(media.tags, isEmpty); + expect(media.favorite, isFalse); + expect(media.deletedAt, isNull); + expect(media.createdAt, isNull); + }); + }); + + // --------------------------------------------------------------------------- + // MediaSet + // --------------------------------------------------------------------------- + group('MediaSet', () { + final Map<String, dynamic> json = { + 'id': 10, + 'name': 'My Videos', + 'root_path': '/media/videos', + 'cover_thumbnail_path': '/thumbs/cover.jpg', + 'is_podcast': false, + 'created_at': '2024-03-01T08:00:00.000Z', + }; + + test('fromJson/toJson round-trip', () { + final ms = MediaSet.fromJson(json); + + expect(ms.id, 10); + expect(ms.name, 'My Videos'); + expect(ms.rootPath, '/media/videos'); + expect(ms.coverThumbnailPath, '/thumbs/cover.jpg'); + expect(ms.isPodcast, isFalse); + expect(ms.createdAt?.toIso8601String(), json['created_at']); + + final out = ms.toJson(); + expect(out['id'], json['id']); + expect(out['name'], json['name']); + expect(out['root_path'], json['root_path']); + expect(out['cover_thumbnail_path'], json['cover_thumbnail_path']); + expect(out['is_podcast'], json['is_podcast']); + expect(out['created_at'], json['created_at']); + }); + + test('fromJson applies defaults for all missing fields', () { + final ms = MediaSet.fromJson({}); + + expect(ms.id, 0); + expect(ms.name, ''); + expect(ms.rootPath, ''); + expect(ms.coverThumbnailPath, ''); + expect(ms.isPodcast, isFalse); + expect(ms.createdAt, isNull); + }); + }); + + // --------------------------------------------------------------------------- + // User + // --------------------------------------------------------------------------- + group('User', () { + final Map<String, dynamic> json = { + 'id': 42, + 'username': 'alice', + 'is_admin': true, + 'created_at': '2024-02-10T12:00:00.000Z', + }; + + test('fromJson/toJson round-trip', () { + final user = User.fromJson(json); + + expect(user.id, 42); + expect(user.username, 'alice'); + expect(user.isAdmin, isTrue); + expect(user.createdAt?.toIso8601String(), json['created_at']); + + final out = user.toJson(); + expect(out['id'], json['id']); + expect(out['username'], json['username']); + expect(out['is_admin'], json['is_admin']); + expect(out['created_at'], json['created_at']); + }); + + test('fromJson applies defaults for all missing fields', () { + final user = User.fromJson({}); + + expect(user.id, 0); + expect(user.username, ''); + expect(user.isAdmin, isFalse); + expect(user.createdAt, isNull); + }); + }); + + // --------------------------------------------------------------------------- + // Tag + // --------------------------------------------------------------------------- + group('Tag', () { + final Map<String, dynamic> json = {'id': 7, 'name': 'sci-fi'}; + + test('fromJson/toJson round-trip', () { + final tag = Tag.fromJson(json); + + expect(tag.id, 7); + expect(tag.name, 'sci-fi'); + + final out = tag.toJson(); + expect(out['id'], json['id']); + expect(out['name'], json['name']); + }); + + test('fromJson applies defaults for all missing fields', () { + final tag = Tag.fromJson({}); + + expect(tag.id, 0); + expect(tag.name, ''); + }); + }); + + // --------------------------------------------------------------------------- + // Note + // --------------------------------------------------------------------------- + group('Note', () { + final Map<String, dynamic> json = { + 'id': 5, + 'media_id': 100, + 'user_id': 42, + 'content': 'Great scene at 00:45', + 'created_at': '2024-04-01T09:00:00.000Z', + 'updated_at': '2024-04-02T09:00:00.000Z', + }; + + test('fromJson/toJson round-trip', () { + final note = Note.fromJson(json); + + expect(note.id, 5); + expect(note.mediaId, 100); + expect(note.userId, 42); + expect(note.content, 'Great scene at 00:45'); + expect(note.createdAt?.toIso8601String(), json['created_at']); + expect(note.updatedAt?.toIso8601String(), json['updated_at']); + + final out = note.toJson(); + expect(out['id'], json['id']); + expect(out['media_id'], json['media_id']); + expect(out['user_id'], json['user_id']); + expect(out['content'], json['content']); + expect(out['created_at'], json['created_at']); + expect(out['updated_at'], json['updated_at']); + }); + + test('fromJson applies defaults for all missing fields', () { + final note = Note.fromJson({}); + + expect(note.id, 0); + expect(note.mediaId, 0); + expect(note.userId, 0); + expect(note.content, ''); + expect(note.createdAt, isNull); + expect(note.updatedAt, isNull); + }); + }); + + // --------------------------------------------------------------------------- + // Share + // --------------------------------------------------------------------------- + group('Share', () { + final Map<String, dynamic> json = { + 'token': 'abc123', + 'media_id': 200, + 'created_by': 1, + 'created_at': '2024-05-01T00:00:00.000Z', + 'expires_at': '2024-06-01T00:00:00.000Z', + 'max_uses': 5, + 'used_count': 2, + }; + + test('fromJson/toJson round-trip with optional fields present', () { + final share = Share.fromJson(json); + + expect(share.token, 'abc123'); + expect(share.mediaId, 200); + expect(share.createdBy, 1); + expect(share.createdAt?.toIso8601String(), json['created_at']); + expect(share.expiresAt?.toIso8601String(), json['expires_at']); + expect(share.maxUses, 5); + expect(share.usedCount, 2); + + final out = share.toJson(); + expect(out['token'], json['token']); + expect(out['media_id'], json['media_id']); + expect(out['created_by'], json['created_by']); + expect(out['created_at'], json['created_at']); + expect(out['expires_at'], json['expires_at']); + expect(out['max_uses'], json['max_uses']); + expect(out['used_count'], json['used_count']); + }); + + test('fromJson applies defaults for all missing fields; maxUses stays null', () { + final share = Share.fromJson({}); + + expect(share.token, ''); + expect(share.mediaId, 0); + expect(share.createdBy, 0); + expect(share.maxUses, isNull); + expect(share.usedCount, 0); + expect(share.createdAt, isNull); + expect(share.expiresAt, isNull); + }); + }); + + // --------------------------------------------------------------------------- + // PodcastFeed + // --------------------------------------------------------------------------- + group('PodcastFeed', () { + final Map<String, dynamic> json = { + 'id': 3, + 'set_id': 10, + 'feed_url': 'https://example.com/feed.rss', + 'title': 'My Podcast', + 'description': 'A great show', + 'image_url': 'https://example.com/cover.jpg', + 'last_checked_at': '2024-05-10T06:00:00.000Z', + 'last_etag': '"etag-value"', + 'check_interval_minutes': 60, + 'auto_download': true, + 'consecutive_failures': 0, + 'next_check_at': '2024-05-10T07:00:00.000Z', + 'created_at': '2024-01-01T00:00:00.000Z', + }; + + test('fromJson/toJson round-trip', () { + final feed = PodcastFeed.fromJson(json); + + expect(feed.id, 3); + expect(feed.setId, 10); + expect(feed.feedUrl, 'https://example.com/feed.rss'); + expect(feed.title, 'My Podcast'); + expect(feed.description, 'A great show'); + expect(feed.imageUrl, 'https://example.com/cover.jpg'); + expect(feed.lastCheckedAt?.toIso8601String(), json['last_checked_at']); + expect(feed.lastETag, '"etag-value"'); + expect(feed.checkIntervalMinutes, 60); + expect(feed.autoDownload, isTrue); + expect(feed.consecutiveFailures, 0); + expect(feed.nextCheckAt?.toIso8601String(), json['next_check_at']); + expect(feed.createdAt?.toIso8601String(), json['created_at']); + + final out = feed.toJson(); + expect(out['id'], json['id']); + expect(out['set_id'], json['set_id']); + expect(out['feed_url'], json['feed_url']); + expect(out['title'], json['title']); + expect(out['description'], json['description']); + expect(out['image_url'], json['image_url']); + expect(out['last_checked_at'], json['last_checked_at']); + expect(out['last_etag'], json['last_etag']); + expect(out['check_interval_minutes'], json['check_interval_minutes']); + expect(out['auto_download'], json['auto_download']); + expect(out['consecutive_failures'], json['consecutive_failures']); + expect(out['next_check_at'], json['next_check_at']); + expect(out['created_at'], json['created_at']); + }); + + test('fromJson applies defaults for all missing fields', () { + final feed = PodcastFeed.fromJson({}); + + expect(feed.id, 0); + expect(feed.setId, 0); + expect(feed.feedUrl, ''); + expect(feed.title, ''); + expect(feed.description, ''); + expect(feed.imageUrl, ''); + expect(feed.lastETag, ''); + expect(feed.checkIntervalMinutes, 0); + expect(feed.autoDownload, isFalse); + expect(feed.consecutiveFailures, 0); + expect(feed.lastCheckedAt, isNull); + expect(feed.nextCheckAt, isNull); + expect(feed.createdAt, isNull); + }); + }); + + // --------------------------------------------------------------------------- + // PodcastEpisode + // --------------------------------------------------------------------------- + group('PodcastEpisode', () { + // Use an integer for position_seconds to cover the num-to-double cast. + final Map<String, dynamic> json = { + 'id': 99, + 'feed_id': 3, + 'media_id': 55, + 'guid': 'ep-guid-abc', + 'title': 'Episode One', + 'description': 'First episode', + 'published_at': '2024-04-01T00:00:00.000Z', + 'episode_url': 'https://example.com/ep1.mp3', + 'duration_seconds': 3600.0, + 'file_size': 102400, + 'file_name': 'ep1.mp3', + 'is_downloaded': true, + 'is_completed': false, + 'position_seconds': 120, // int in JSON; must be cast to double + 'created_at': '2024-04-01T01:00:00.000Z', + }; + + test('fromJson/toJson round-trip with optional fields present', () { + final ep = PodcastEpisode.fromJson(json); + + expect(ep.id, 99); + expect(ep.feedId, 3); + expect(ep.mediaId, 55); + expect(ep.guid, 'ep-guid-abc'); + expect(ep.title, 'Episode One'); + expect(ep.description, 'First episode'); + expect(ep.publishedAt?.toIso8601String(), json['published_at']); + expect(ep.episodeUrl, 'https://example.com/ep1.mp3'); + expect(ep.durationSeconds, 3600.0); + expect(ep.fileSize, 102400); + expect(ep.fileName, 'ep1.mp3'); + expect(ep.isDownloaded, isTrue); + expect(ep.isCompleted, isFalse); + expect(ep.positionSeconds, 120.0); // int input becomes double + expect(ep.createdAt?.toIso8601String(), json['created_at']); + + final out = ep.toJson(); + expect(out['id'], json['id']); + expect(out['feed_id'], json['feed_id']); + expect(out['media_id'], json['media_id']); + expect(out['guid'], json['guid']); + expect(out['title'], json['title']); + expect(out['description'], json['description']); + expect(out['published_at'], json['published_at']); + expect(out['episode_url'], json['episode_url']); + expect(out['duration_seconds'], json['duration_seconds']); + expect(out['file_size'], json['file_size']); + expect(out['file_name'], json['file_name']); + expect(out['is_downloaded'], json['is_downloaded']); + expect(out['is_completed'], json['is_completed']); + expect(out['position_seconds'], 120.0); // value comes back as double + expect(out['created_at'], json['created_at']); + }); + + test('fromJson applies defaults; nullable fields stay null', () { + final ep = PodcastEpisode.fromJson({}); + + expect(ep.id, 0); + expect(ep.feedId, 0); + expect(ep.mediaId, isNull); + expect(ep.guid, ''); + expect(ep.title, ''); + expect(ep.description, ''); + expect(ep.episodeUrl, ''); + expect(ep.fileName, ''); + expect(ep.durationSeconds, isNull); + expect(ep.fileSize, isNull); + expect(ep.isDownloaded, isFalse); + expect(ep.isCompleted, isFalse); + expect(ep.positionSeconds, 0.0); + expect(ep.publishedAt, isNull); + expect(ep.createdAt, isNull); + }); + }); + + // --------------------------------------------------------------------------- + // PlaybackHint + // --------------------------------------------------------------------------- + group('PlaybackHint', () { + final Map<String, dynamic> json = { + 'media_id': 77, + 'position_seconds': 250.5, + 'finished': true, + 'updated_at': '2024-06-01T14:30:00.000Z', + }; + + test('fromJson/toJson round-trip', () { + final hint = PlaybackHint.fromJson(json); + + expect(hint.mediaId, 77); + expect(hint.positionSeconds, 250.5); + expect(hint.finished, isTrue); + expect(hint.updatedAt?.toIso8601String(), json['updated_at']); + + final out = hint.toJson(); + expect(out['media_id'], json['media_id']); + expect(out['position_seconds'], json['position_seconds']); + expect(out['finished'], json['finished']); + expect(out['updated_at'], json['updated_at']); + }); + + test('fromJson applies defaults for all missing fields', () { + final hint = PlaybackHint.fromJson({}); + + expect(hint.mediaId, 0); + expect(hint.positionSeconds, 0.0); + expect(hint.finished, isFalse); + expect(hint.updatedAt, isNull); + }); + }); + + // --------------------------------------------------------------------------- + // PlayerApiClient constructor + // --------------------------------------------------------------------------- + group('PlayerApiClient constructor', () { + test('stores baseUrl and bearerToken when valid', () { + // The client stores the Uri and token as-is; no transformation occurs. + final client = PlayerApiClient( + baseUrl: Uri.parse('https://player.example.com'), + bearerToken: 'my-secret-token', + ); + + expect(client.baseUrl.toString(), 'https://player.example.com'); + expect(client.bearerToken, 'my-secret-token'); + }); + + test('baseUrl with path component is stored verbatim', () { + // Verify the full URI including trailing path is preserved unchanged. + final client = PlayerApiClient( + baseUrl: Uri.parse('https://player.example.com/api/v1'), + bearerToken: 'tok', + ); + + expect(client.baseUrl.path, '/api/v1'); + expect(client.baseUrl.host, 'player.example.com'); + }); + + test('stores an empty bearerToken unchanged', () { + // A missing or empty token is allowed at construction time; the server + // will reject unauthenticated requests at call time. + final client = PlayerApiClient( + baseUrl: Uri.parse('https://player.example.com'), + bearerToken: '', + ); + + expect(client.bearerToken, ''); + }); + }); +} |
