It works baby, seems like timing could be improved or something, but it freakin works

This commit is contained in:
Nate Anderson
2025-06-18 20:59:24 -06:00
parent b68614257d
commit 0745a4eb75
50 changed files with 7679 additions and 20 deletions
+103
View File
@@ -0,0 +1,103 @@
# MumBullet Integration Tests
This directory contains integration tests for the MumBullet application.
## Prerequisites
- Docker and Docker Compose installed and running
- Dart SDK installed
## Running Tests
The integration tests will automatically:
1. Check if Docker is running
2. Start the required Docker containers
3. Run the tests
4. Clean up containers afterward
To run all tests:
```bash
dart test
```
To run only integration tests:
```bash
dart test test/integration/
```
To run a specific test:
```bash
dart test test/integration/connection_test.dart
```
## Development Environment
For development, use the main docker-compose.yml in the project root:
```bash
# Start the development server
docker-compose up -d
# Run the bot
dart run bin/mumbullet.dart --config test/fixtures/test_config.json
# Stop the development server
docker-compose down
```
## Integration Test Environment
The integration tests use a separate Docker Compose file:
```bash
# Run tests (Docker is started automatically)
dart test test/integration/
# Manually start test environment if needed
docker-compose -f test/integration/docker-compose.test.yml up -d
# Manually stop test environment
docker-compose -f test/integration/docker-compose.test.yml down -v
```
## Test Configuration
The tests use special configuration files in the `test/fixtures/` directory:
- `test_config.json`: Main test bot configuration
- `test_configs/admin_config.json`: Admin bot configuration
- `test_configs/user_config.json`: User bot configuration
All configurations point to the Docker Mumble server running on localhost:64738.
## Server Configuration
The Docker-based Mumble server is pre-configured with:
- Server password: `serverpassword`
- Multiple channels: `Music`, `General`, `Gaming`, and their subchannels
- Default channel: `Music`
This setup allows testing different authentication and channel navigation scenarios.
## Project Structure
```
test/
├── fixtures/ # Test fixtures and configurations
│ ├── cache/ # Cache directory for tests
│ ├── murmur.ini # Mumble server configuration
│ ├── test_config.json # Main test configuration
│ └── test_configs/ # Additional test configurations
│ ├── admin_config.json
│ └── user_config.json
└── integration/ # Integration tests
├── connection_test.dart # Tests for Mumble connection
├── docker-compose.test.yml # Docker setup for integration tests
└── README.md # This file
```
The integration tests use a separate Docker Compose setup from the development environment to ensure consistent, isolated testing.
@@ -0,0 +1,320 @@
import 'dart:io';
import 'package:test/test.dart';
import 'package:path/path.dart' as path;
import 'package:mumbullet/src/audio/downloader.dart';
import 'package:mumbullet/src/command/command_handler.dart';
import 'package:mumbullet/src/command/command_parser.dart';
import 'package:mumbullet/src/config/config.dart';
import 'package:mumbullet/src/queue/music_queue.dart';
import 'package:mumbullet/src/storage/database.dart';
/// Mock command context for testing
class MockCommandContext {
final String args;
final int permissionLevel;
final List<String> replies = [];
MockCommandContext({
required this.args,
this.permissionLevel = 2,
});
Future<void> reply(String message) async {
replies.add(message);
}
}
void main() {
group('Command Handler with Downloader Integration Tests', () {
late CommandHandler commandHandler;
late YoutubeDownloader downloader;
late DatabaseManager database;
late BotConfig config;
late Directory tempDir;
late String tempDbPath;
late CommandParser commandParser;
late MusicQueue musicQueue;
setUpAll(() async {
// Check if yt-dlp is available
try {
final result = await Process.run('yt-dlp', ['--version']);
if (result.exitCode != 0) {
throw Exception('yt-dlp not available');
}
} catch (e) {
print('Skipping yt-dlp integration tests: yt-dlp not found in PATH');
return;
}
});
setUp(() async {
// Create temporary directory for cache
tempDir = await Directory.systemTemp.createTemp('mumbullet_cmd_test_');
// Create temporary database
tempDbPath = path.join(tempDir.path, 'test.db');
// Create test configuration
config = BotConfig(
commandPrefix: '!',
defaultPermissionLevel: 1,
maxQueueSize: 10,
cacheDirectory: tempDir.path,
maxCacheSizeGb: 0.1, // 100MB for testing
);
// Initialize components
database = DatabaseManager(tempDbPath);
downloader = YoutubeDownloader(config, database);
commandParser = CommandParser();
musicQueue = MusicQueue();
// Create command handler
commandHandler = CommandHandler(
commandParser,
musicQueue,
downloader,
config,
);
});
tearDown(() async {
// Clean up
database.close();
if (tempDir.existsSync()) {
await tempDir.delete(recursive: true);
}
});
group('Play Command Integration', () {
test('should download and play valid YouTube URL', () async {
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
final context = MockCommandContext(args: testUrl);
// Get the play command
final playCommand = commandParser.getCommand('play');
expect(playCommand, isNotNull);
// Execute play command
await playCommand!.execute(context);
// Verify responses
expect(context.replies.length, equals(2));
expect(context.replies[0], contains('Downloading audio from:'));
expect(context.replies[0], contains(testUrl));
expect(context.replies[1], startsWith('Now playing:'));
// Verify queue state
expect(musicQueue.currentSong, isNotNull);
expect(musicQueue.currentSong!.url, equals(testUrl));
// Verify file was downloaded
final file = File(musicQueue.currentSong!.filePath);
expect(file.existsSync(), isTrue);
}, timeout: Timeout(Duration(minutes: 2)));
test('should handle HTML anchor tag URLs in play command', () async {
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
const htmlInput = '<a href="$testUrl">YouTube Video</a>';
final context = MockCommandContext(args: htmlInput);
final playCommand = commandParser.getCommand('play');
await playCommand!.execute(context);
// Should extract URL from HTML and download
expect(context.replies.length, equals(2));
expect(context.replies[0], contains(testUrl));
expect(context.replies[1], startsWith('Now playing:'));
expect(musicQueue.currentSong, isNotNull);
expect(musicQueue.currentSong!.url, equals(testUrl));
}, timeout: Timeout(Duration(minutes: 2)));
test('should handle play command with invalid URL', () async {
const invalidUrl = 'https://www.youtube.com/watch?v=InvalidVideoId123';
final context = MockCommandContext(args: invalidUrl);
final playCommand = commandParser.getCommand('play');
await playCommand!.execute(context);
// Should show download attempt and then error
expect(context.replies.length, equals(2));
expect(context.replies[0], contains('Downloading audio from:'));
expect(context.replies[1], startsWith('Failed to download or play'));
// Queue should be empty
expect(musicQueue.currentSong, isNull);
}, timeout: Timeout(Duration(minutes: 1)));
});
group('Queue Command Integration', () {
test('should download and queue valid YouTube URL', () async {
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
final context = MockCommandContext(args: testUrl, permissionLevel: 1);
final queueCommand = commandParser.getCommand('queue');
expect(queueCommand, isNotNull);
await queueCommand!.execute(context);
// Verify responses
expect(context.replies.length, equals(2));
expect(context.replies[0], contains('Adding to queue:'));
expect(context.replies[1], startsWith('Now playing:')); // First song starts playing
// Verify queue state
expect(musicQueue.currentSong, isNotNull);
expect(musicQueue.currentSong!.url, equals(testUrl));
}, timeout: Timeout(Duration(minutes: 2)));
test('should queue multiple songs', () async {
const testUrls = [
'https://www.youtube.com/watch?v=jNQXAC9IVRw',
'https://youtu.be/jNQXAC9IVRw', // Same video, different format
];
for (int i = 0; i < testUrls.length; i++) {
final context = MockCommandContext(args: testUrls[i], permissionLevel: 1);
final queueCommand = commandParser.getCommand('queue');
await queueCommand!.execute(context);
if (i == 0) {
// First song starts playing
expect(context.replies.last, startsWith('Now playing:'));
} else {
// Subsequent songs are queued (but may use cache)
expect(context.replies.last, anyOf(
startsWith('Now playing:'), // If cached, might start immediately
startsWith('Added to queue at position'),
));
}
}
// Verify queue has songs
final queue = musicQueue.getQueue();
expect(queue.length, greaterThanOrEqualTo(1));
}, timeout: Timeout(Duration(minutes: 3)));
test('should handle queue command with invalid URL', () async {
const invalidUrl = 'not-a-valid-url';
final context = MockCommandContext(args: invalidUrl, permissionLevel: 1);
final queueCommand = commandParser.getCommand('queue');
await queueCommand!.execute(context);
// Should show error
expect(context.replies.length, equals(2));
expect(context.replies[0], contains('Adding to queue:'));
expect(context.replies[1], startsWith('Failed to download or queue'));
// Queue should be empty
expect(musicQueue.getQueue(), isEmpty);
}, timeout: Timeout(Duration(minutes: 1)));
});
group('Shuffle Command Integration', () {
test('should shuffle cached songs', () async {
// First, download a few songs to cache
const testUrls = [
'https://www.youtube.com/watch?v=jNQXAC9IVRw',
];
// Download songs to cache
for (final url in testUrls) {
await downloader.download(url);
}
// Now test shuffle command
final context = MockCommandContext(args: '', permissionLevel: 2);
final shuffleCommand = commandParser.getCommand('shuffle');
await shuffleCommand!.execute(context);
// Should have shuffled and started playing
expect(context.replies.length, equals(1));
expect(context.replies[0], contains('Added'));
expect(context.replies[0], contains('shuffled songs'));
expect(context.replies[0], contains('Now playing:'));
// Queue should have songs
final queue = musicQueue.getQueue();
expect(queue.length, greaterThan(0));
expect(musicQueue.currentSong, isNotNull);
}, timeout: Timeout(Duration(minutes: 2)));
test('should handle shuffle with no cached songs', () async {
// Clear any existing cache
await downloader.clearCache();
final context = MockCommandContext(args: '', permissionLevel: 2);
final shuffleCommand = commandParser.getCommand('shuffle');
await shuffleCommand!.execute(context);
// Should indicate no cached songs
expect(context.replies.length, equals(1));
expect(context.replies[0], contains('No cached songs found'));
});
});
group('URL Format Handling', () {
test('should handle various YouTube URL formats in commands', () async {
final testCases = [
'https://www.youtube.com/watch?v=jNQXAC9IVRw',
'https://youtube.com/watch?v=jNQXAC9IVRw',
'https://youtu.be/jNQXAC9IVRw',
'https://m.youtube.com/watch?v=jNQXAC9IVRw',
'<a href="https://www.youtube.com/watch?v=jNQXAC9IVRw">Video Link</a>',
];
for (final testUrl in testCases) {
// Clear queue for each test
musicQueue.clear();
final context = MockCommandContext(args: testUrl);
final playCommand = commandParser.getCommand('play');
await playCommand!.execute(context);
// Should successfully download and play
expect(context.replies.length, equals(2));
expect(context.replies[1], startsWith('Now playing:'));
expect(musicQueue.currentSong, isNotNull);
// All should result in the same video title (same video)
expect(musicQueue.currentSong!.title, isNotEmpty);
}
}, timeout: Timeout(Duration(minutes: 5)));
});
group('Permission Handling', () {
test('should respect permission levels for commands', () async {
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
// Test play command with insufficient permissions
final lowPermContext = MockCommandContext(args: testUrl, permissionLevel: 1);
final playCommand = commandParser.getCommand('play');
// Play command requires level 2, user has level 1
expect(playCommand!.requiredPermissionLevel, equals(2));
// This would normally be handled by the command parser's permission check
// but we're testing the command directly, so we'll verify the requirement
expect(lowPermContext.permissionLevel, lessThan(playCommand.requiredPermissionLevel));
// Test queue command with sufficient permissions
final queueContext = MockCommandContext(args: testUrl, permissionLevel: 1);
final queueCommand = commandParser.getCommand('queue');
expect(queueCommand!.requiredPermissionLevel, equals(1));
expect(queueContext.permissionLevel, greaterThanOrEqualTo(queueCommand.requiredPermissionLevel));
await queueCommand.execute(queueContext);
expect(queueContext.replies.length, equals(2));
expect(queueContext.replies[1], startsWith('Now playing:'));
}, timeout: Timeout(Duration(minutes: 2)));
});
}, skip: Platform.environment['SKIP_INTEGRATION_TESTS'] == 'true');
}
+272
View File
@@ -0,0 +1,272 @@
import 'dart:async';
import 'dart:io';
import 'package:mumbullet/mumbullet.dart';
import 'package:test/test.dart';
/// Integration test for Mumble connection
///
/// This test:
/// - Requires Docker to be running
/// - Uses a dedicated docker-compose.test.yml file
/// - Creates a Mumble server with predefined channels
/// - Tests connection, authentication, and channel navigation
///
/// The server is automatically started before tests and stopped after tests
/// using docker-compose commands.
@Tags(['docker'])
void main() {
// Use setUpAll and tearDownAll to ensure Docker is only started/stopped once for all tests
late MumbleConnection connection;
late AppConfig config;
const rootPath = '/home/nate/source/non-work/mumbullet';
const configPath = '$rootPath/test/fixtures/test_config.json';
setUpAll(() async {
// Create cache directory if it doesn't exist
final cacheDir = Directory('$rootPath/test/fixtures/cache');
if (!await cacheDir.exists()) {
await cacheDir.create(recursive: true);
}
// Check if Docker is running
try {
final checkDocker = await Process.run('docker', ['info']);
if (checkDocker.exitCode != 0) {
fail('Docker is not running. Please start Docker and try again.');
}
} catch (e) {
fail('Failed to check Docker status: $e. Is Docker installed?');
}
// Start Docker container using test-specific compose file
print('Starting Mumble server container for tests...');
final dockerProcess = await Process.run(
'docker-compose',
['-f', 'test/integration/docker-compose.test.yml', 'up', '-d'],
workingDirectory: rootPath
);
if (dockerProcess.exitCode != 0) {
fail('Failed to start Docker container: ${dockerProcess.stderr}');
}
// Wait for server to start
print('Waiting for Mumble server to start...');
await Future.delayed(Duration(seconds: 5));
});
tearDownAll(() async {
// Stop Docker container
print('Stopping Mumble server container...');
final stopProcess = await Process.run(
'docker-compose',
['-f', 'test/integration/docker-compose.test.yml', 'down', '-v'],
workingDirectory: rootPath
);
if (stopProcess.exitCode != 0) {
print('Warning: Failed to stop Docker container: ${stopProcess.stderr}');
}
});
setUp(() async {
// Load test configuration
config = AppConfig.fromFile(configPath);
// Create connection
connection = MumbleConnection(config.mumble);
});
tearDown(() async {
// Disconnect from server
if (connection.isConnected) {
await connection.disconnect();
}
});
test('Should connect to Mumble server with password', () async {
// Setup test expectations
final connectionCompleter = Completer<bool>();
// Listen for connection state changes
connection.connectionState.listen((isConnected) {
if (isConnected && !connectionCompleter.isCompleted) {
connectionCompleter.complete(true);
}
});
// Attempt to connect with password
await connection.connect();
// Wait for connection or timeout
expect(
await connectionCompleter.future.timeout(Duration(seconds: 10), onTimeout: () => false),
isTrue,
reason: 'Failed to connect to Mumble server with password',
);
// Verify connection is established
expect(connection.isConnected, isTrue);
// Verify we can get channel information
expect(connection.client, isNotNull);
expect(connection.client!.rootChannel, isNotNull);
// Verify we can send a message
await connection.sendChannelMessage('Integration test message');
});
test('Should join different channels', () async {
// Setup test expectations
final connectionCompleter = Completer<bool>();
// Listen for connection state changes
connection.connectionState.listen((isConnected) {
if (isConnected && !connectionCompleter.isCompleted) {
connectionCompleter.complete(true);
}
});
// Connect to server
await connection.connect();
// Wait for connection
expect(await connectionCompleter.future.timeout(Duration(seconds: 10), onTimeout: () => false), isTrue);
expect(connection.client, isNotNull);
final channels = connection.client?.getChannels();
print(channels?.values);
expect(channels, isNotNull);
expect(channels?.length, greaterThan(1));
// Test joining the Music channel
await connection.joinChannel('Music');
expect(connection.currentChannel?.name, equals('Music'));
// Test joining a subchannel
await connection.joinChannel('Subroom1');
expect(connection.currentChannel?.name, equals('Subroom1'));
// Test joining another top-level channel
await connection.joinChannel('General');
expect(connection.currentChannel?.name, equals('General'));
// Test joining the Gaming channel
await connection.joinChannel('Gaming');
expect(connection.currentChannel?.name, equals('Gaming'));
// Test joining the root channel
await connection.joinChannel('');
expect(connection.currentChannel?.name, equals('Root'));
});
test('Should handle authentication failure', () async {
// Create a connection with incorrect password
final badConnection = MumbleConnection(
MumbleConfig(
server: config.mumble.server,
port: config.mumble.port,
username: config.mumble.username,
password: 'wrongpassword',
channel: config.mumble.channel,
),
);
// Try to connect with wrong password
try {
await badConnection.connect();
fail('Should have failed to connect with wrong password');
} catch (e) {
// Expected failure
expect(badConnection.isConnected, isFalse);
} finally {
// Clean up
await badConnection.disconnect();
}
});
// test('Should allow multiple bots to connect simultaneously', () async {
// // Load additional config
// final adminConfig = AppConfig.fromFile('${rootPath}/test/fixtures/test_configs/admin_config.json');
// final userConfig = AppConfig.fromFile('${rootPath}/test/fixtures/test_configs/user_config.json');
// // Create additional connections
// final adminConnection = MumbleConnection(adminConfig.mumble);
// final userConnection = MumbleConnection(userConfig.mumble);
// try {
// // Connect all bots
// await Future.wait([adminConnection.connect(), userConnection.connect()]);
// // Verify all connections are established
// expect(adminConnection.isConnected, isTrue);
// expect(userConnection.isConnected, isTrue);
// // Test sending messages from different bots
// await adminConnection.sendChannelMessage('Admin bot test message');
// await userConnection.sendChannelMessage('User bot test message');
// // Test joining different channels with each bot
// await adminConnection.joinChannel('Gaming');
// await userConnection.joinChannel('General');
// expect(adminConnection.currentChannel?.name, equals('Gaming'));
// expect(userConnection.currentChannel?.name, equals('General'));
// } finally {
// // Clean up
// await adminConnection.disconnect();
// await userConnection.disconnect();
// }
// });
test('Should handle reconnection', () async {
// Setup test expectations
final connectionCompleter = Completer<bool>();
final reconnectionCompleter = Completer<bool>();
var initiallyConnected = false;
// Listen for connection state changes
connection.connectionState.listen((isConnected) {
if (isConnected && !initiallyConnected) {
initiallyConnected = true;
connectionCompleter.complete(true);
} else if (isConnected && initiallyConnected && !reconnectionCompleter.isCompleted) {
reconnectionCompleter.complete(true);
}
});
// Connect initially
await connection.connect();
// Wait for initial connection
expect(
await connectionCompleter.future.timeout(Duration(seconds: 10), onTimeout: () => false),
isTrue,
reason: 'Failed to connect to Mumble server initially',
);
// Restart the Mumble server
print('Restarting Mumble server container to test reconnection...');
final restartProcess = await Process.run(
'docker-compose',
['-f', 'test/integration/docker-compose.test.yml', 'restart', 'mumble'],
workingDirectory: rootPath
);
if (restartProcess.exitCode != 0) {
fail('Failed to restart Mumble server: ${restartProcess.stderr}');
}
// Wait for reconnection
await expectLater(
reconnectionCompleter.future.timeout(Duration(seconds: 20), onTimeout: () => false),
isTrue,
reason: 'Failed to reconnect to Mumble server after restart',
);
// Verify connection is re-established
expect(connection.isConnected, isTrue);
});
}
+7
View File
@@ -0,0 +1,7 @@
tags:
docker:
# Integration tests that require Docker to be running
on_os:
- linux
- mac-os
timeout: 60s
+20
View File
@@ -0,0 +1,20 @@
services:
mumble:
image: mumblevoip/mumble-server:latest
container_name: mumble-server
restart: on-failure
ports:
- "64738:64738/udp" # Mumble voice port
- "64738:64738" # Web interface/WebRTC port (optional)
volumes:
- ../fixtures/murmur.ini:/etc/mumble-server.ini:ro
# Create a persistent volume for database
- mumble-test-data:/var/lib/mumble-server
environment:
- SUPERUSER_PASSWORD=supersecret
# We can still set environment variables to override config if needed
volumes:
mumble-test-data:
# This ensures data is cleaned up when containers are removed
driver: local
@@ -0,0 +1,256 @@
import 'dart:io';
import 'package:test/test.dart';
import 'package:path/path.dart' as path;
import 'package:mumbullet/src/audio/downloader.dart';
import 'package:mumbullet/src/command/command_handler.dart';
import 'package:mumbullet/src/config/config.dart';
import 'package:mumbullet/src/storage/database.dart';
void main() {
group('Downloader Integration Tests', () {
late YoutubeDownloader downloader;
late DatabaseManager database;
late BotConfig config;
late Directory tempDir;
late String tempDbPath;
setUpAll(() async {
// Check if yt-dlp is available
try {
final result = await Process.run('yt-dlp', ['--version']);
if (result.exitCode != 0) {
throw Exception('yt-dlp not available');
}
} catch (e) {
print('Skipping yt-dlp tests: yt-dlp not found in PATH');
return;
}
});
setUp(() async {
// Create temporary directory for cache
tempDir = await Directory.systemTemp.createTemp('mumbullet_integration_');
// Create temporary database
tempDbPath = path.join(tempDir.path, 'test.db');
// Create test configuration
config = BotConfig(
commandPrefix: '!',
defaultPermissionLevel: 1,
maxQueueSize: 10,
cacheDirectory: tempDir.path,
maxCacheSizeGb: 0.1, // 100MB for testing
);
// Initialize database
database = DatabaseManager(tempDbPath);
// Create downloader
downloader = YoutubeDownloader(config, database);
});
tearDown(() async {
// Clean up
try {
database.close();
} catch (e) {
// Ignore close errors
}
if (tempDir.existsSync()) {
await tempDir.delete(recursive: true);
}
});
group('Real YouTube Downloads', () {
test('should successfully download the test YouTube video', () async {
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; // "Me at the zoo"
final song = await downloader.download(testUrl);
// Verify the download worked
expect(song.url, equals(testUrl));
expect(song.title, equals('Me at the zoo'));
expect(song.duration, equals(19));
expect(song.filePath, isNotEmpty);
expect(song.id, greaterThan(0));
// Verify the file exists and has content
final file = File(song.filePath);
expect(file.existsSync(), isTrue);
expect(file.lengthSync(), greaterThan(1000000)); // Should be > 1MB
print('✓ Successfully downloaded: ${song.title} (${song.duration}s)');
print(' File: ${song.filePath} (${(file.lengthSync() / 1024 / 1024).toStringAsFixed(1)} MB)');
}, timeout: Timeout(Duration(minutes: 2)));
test('should handle different YouTube URL formats for same video', () async {
final testUrls = [
'https://www.youtube.com/watch?v=jNQXAC9IVRw',
'https://youtu.be/jNQXAC9IVRw',
'https://m.youtube.com/watch?v=jNQXAC9IVRw',
];
final songs = <Song>[];
for (final url in testUrls) {
final song = await downloader.download(url);
songs.add(song);
expect(song.title, equals('Me at the zoo'));
expect(song.duration, equals(19));
print('✓ URL format test: $url -> ${song.title}');
}
// All should have the same title and duration
for (final song in songs) {
expect(song.title, equals(songs.first.title));
expect(song.duration, equals(songs.first.duration));
}
}, timeout: Timeout(Duration(minutes: 4)));
test('should properly cache downloads', () async {
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
// First download - should take time
final stopwatch1 = Stopwatch()..start();
final song1 = await downloader.download(testUrl);
stopwatch1.stop();
// Second download - should be much faster (cached)
final stopwatch2 = Stopwatch()..start();
final song2 = await downloader.download(testUrl);
stopwatch2.stop();
// Verify same song returned
expect(song1.id, equals(song2.id));
expect(song1.filePath, equals(song2.filePath));
expect(song1.title, equals(song2.title));
// Second call should be much faster
expect(stopwatch2.elapsedMilliseconds, lessThan(stopwatch1.elapsedMilliseconds ~/ 3));
print('✓ Cache test: First download: ${stopwatch1.elapsedMilliseconds}ms, Second: ${stopwatch2.elapsedMilliseconds}ms');
}, timeout: Timeout(Duration(minutes: 3)));
});
group('Error Handling', () {
test('should handle invalid YouTube URLs', () async {
final invalidUrls = [
'https://www.youtube.com/watch?v=InvalidVideoId123456',
'https://www.youtube.com/watch?v=',
'not-a-url-at-all',
'https://invalid-domain.com/video',
];
for (final url in invalidUrls) {
try {
await downloader.download(url);
fail('Should have thrown exception for invalid URL: $url');
} catch (e) {
expect(e, isA<Exception>());
print('✓ Correctly rejected invalid URL: $url');
}
}
});
test('should handle network issues gracefully', () async {
// This URL should timeout or fail
const problematicUrl = 'https://httpstat.us/500';
try {
await downloader.download(problematicUrl);
fail('Should have thrown exception for problematic URL');
} catch (e) {
expect(e, isA<Exception>());
print('✓ Correctly handled network issue: ${e.toString()}');
}
}, timeout: Timeout(Duration(seconds: 30)));
});
group('Cache Management', () {
test('should provide accurate cache statistics', () async {
// Initially empty
final initialStats = await downloader.getCacheStats();
expect(initialStats['songCount'], equals(0));
expect(initialStats['totalSizeBytes'], equals(0));
// Download a video
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
final song = await downloader.download(testUrl);
// Check updated stats
final updatedStats = await downloader.getCacheStats();
expect(updatedStats['songCount'], equals(1));
expect(updatedStats['totalSizeBytes'], greaterThan(1000000)); // > 1MB
// Verify file size matches
final file = File(song.filePath);
expect(updatedStats['totalSizeBytes'], equals(file.lengthSync()));
print('✓ Cache stats: ${updatedStats['songCount']} songs, ${updatedStats['totalSizeMb']} MB');
}, timeout: Timeout(Duration(minutes: 2)));
test('should clear cache completely', () async {
// Download a video
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
final song = await downloader.download(testUrl);
final filePath = song.filePath;
// Verify file exists
expect(File(filePath).existsSync(), isTrue);
// Clear cache
await downloader.clearCache();
// Verify cache is empty
final statsAfter = await downloader.getCacheStats();
expect(statsAfter['songCount'], equals(0));
expect(statsAfter['totalSizeBytes'], equals(0));
// Verify file was deleted
expect(File(filePath).existsSync(), isFalse);
print('✓ Cache cleared successfully');
}, timeout: Timeout(Duration(minutes: 2)));
test('should handle missing cached files gracefully', () async {
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
// Download a video
final song1 = await downloader.download(testUrl);
final originalPath = song1.filePath;
// Manually delete the file
await File(originalPath).delete();
// Try to download again - should re-download
final song2 = await downloader.download(testUrl);
// Should have same metadata but new file
expect(song2.title, equals(song1.title));
expect(song2.duration, equals(song1.duration));
expect(File(song2.filePath).existsSync(), isTrue);
print('✓ Re-download after file deletion: ${song2.title}');
}, timeout: Timeout(Duration(minutes: 3)));
});
group('URL Extraction', () {
test('should extract URLs from HTML anchor tags', () async {
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
const htmlInput = '<a href="$testUrl">YouTube Video</a>';
// Test the regex pattern used in command handler
final aTagRegex = RegExp(r'<a\s+href="([^"]+)"[^>]*>.*?</a>', caseSensitive: false);
final match = aTagRegex.firstMatch(htmlInput);
expect(match, isNotNull);
expect(match!.group(1), equals(testUrl));
print('✓ HTML URL extraction works correctly');
});
});
}, skip: Platform.environment['SKIP_INTEGRATION_TESTS'] == 'true');
}