It works baby, seems like timing could be improved or something, but it freakin works
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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/config/config.dart';
|
||||
import 'package:mumbullet/src/storage/database.dart';
|
||||
|
||||
void main() {
|
||||
group('YoutubeDownloader Simple 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_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 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('Basic Functionality', () {
|
||||
test('should download a valid YouTube video and return Song object', () async {
|
||||
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; // "Me at the zoo"
|
||||
|
||||
final song = await downloader.download(testUrl);
|
||||
|
||||
// Verify basic song properties
|
||||
expect(song.url, equals(testUrl));
|
||||
expect(song.title, isNotEmpty);
|
||||
expect(song.title, isNot(equals('Unknown Title')));
|
||||
expect(song.duration, greaterThan(0));
|
||||
expect(song.filePath, isNotEmpty);
|
||||
expect(song.id, greaterThan(0));
|
||||
expect(song.addedAt, isNotNull);
|
||||
|
||||
// Verify file path structure
|
||||
expect(song.filePath, startsWith(tempDir.path));
|
||||
expect(song.filePath, endsWith('.wav'));
|
||||
|
||||
print('Downloaded: ${song.title} (${song.duration}s) -> ${song.filePath}');
|
||||
}, timeout: Timeout(Duration(minutes: 2)));
|
||||
|
||||
test('should use cache on second download', () async {
|
||||
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
|
||||
|
||||
// First download
|
||||
final song1 = await downloader.download(testUrl);
|
||||
|
||||
// Second download (should use cache)
|
||||
final song2 = await downloader.download(testUrl);
|
||||
|
||||
// Should return same song from cache
|
||||
expect(song1.id, equals(song2.id));
|
||||
expect(song1.title, equals(song2.title));
|
||||
expect(song1.filePath, equals(song2.filePath));
|
||||
|
||||
print('Cache test passed: ${song1.title}');
|
||||
}, timeout: Timeout(Duration(minutes: 3)));
|
||||
|
||||
test('should handle invalid URLs gracefully', () async {
|
||||
final invalidUrls = [
|
||||
'not-a-url',
|
||||
'https://invalid-domain.com/video',
|
||||
'https://www.youtube.com/watch?v=InvalidVideoId123',
|
||||
];
|
||||
|
||||
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 handled invalid URL: $url - ${e.toString()}');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should provide cache statistics', () async {
|
||||
// Initially empty
|
||||
final initialStats = await downloader.getCacheStats();
|
||||
expect(initialStats['songCount'], equals(0));
|
||||
|
||||
// Download a video
|
||||
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
|
||||
await downloader.download(testUrl);
|
||||
|
||||
// Check updated stats
|
||||
final updatedStats = await downloader.getCacheStats();
|
||||
expect(updatedStats['songCount'], equals(1));
|
||||
expect(updatedStats['totalSizeBytes'], greaterThan(0));
|
||||
|
||||
print('Cache stats: ${updatedStats['songCount']} songs, ${updatedStats['totalSizeMb']} MB');
|
||||
}, timeout: Timeout(Duration(minutes: 2)));
|
||||
|
||||
test('should clear cache', () async {
|
||||
// Download a video
|
||||
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
|
||||
await downloader.download(testUrl);
|
||||
|
||||
// Verify cache has content
|
||||
final statsBefore = await downloader.getCacheStats();
|
||||
expect(statsBefore['songCount'], greaterThan(0));
|
||||
|
||||
// Clear cache
|
||||
await downloader.clearCache();
|
||||
|
||||
// Verify cache is empty
|
||||
final statsAfter = await downloader.getCacheStats();
|
||||
expect(statsAfter['songCount'], equals(0));
|
||||
expect(statsAfter['totalSizeBytes'], equals(0));
|
||||
|
||||
print('Cache cleared successfully');
|
||||
}, timeout: Timeout(Duration(minutes: 2)));
|
||||
});
|
||||
|
||||
group('URL Format Variations', () {
|
||||
test('should handle different YouTube URL formats', () async {
|
||||
final testUrls = [
|
||||
'https://www.youtube.com/watch?v=jNQXAC9IVRw',
|
||||
'https://youtu.be/jNQXAC9IVRw',
|
||||
];
|
||||
|
||||
String? firstTitle;
|
||||
|
||||
for (final url in testUrls) {
|
||||
final song = await downloader.download(url);
|
||||
|
||||
expect(song.title, isNotEmpty);
|
||||
expect(song.duration, greaterThan(0));
|
||||
|
||||
if (firstTitle == null) {
|
||||
firstTitle = song.title;
|
||||
} else {
|
||||
// All URLs should resolve to the same video
|
||||
expect(song.title, equals(firstTitle));
|
||||
}
|
||||
|
||||
print('URL format test: $url -> ${song.title}');
|
||||
}
|
||||
}, timeout: Timeout(Duration(minutes: 3)));
|
||||
});
|
||||
|
||||
group('Edge Cases', () {
|
||||
test('should handle re-download when cached file is missing', () 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 (simulating external deletion)
|
||||
if (File(originalPath).existsSync()) {
|
||||
await File(originalPath).delete();
|
||||
}
|
||||
|
||||
// Try to download again - should re-download
|
||||
final song2 = await downloader.download(testUrl);
|
||||
|
||||
// Should have same title but may have different file path
|
||||
expect(song2.title, equals(song1.title));
|
||||
expect(song2.filePath, isNotEmpty);
|
||||
|
||||
print('Re-download test passed: ${song2.title}');
|
||||
}, timeout: Timeout(Duration(minutes: 3)));
|
||||
});
|
||||
}, skip: Platform.environment['SKIP_INTEGRATION_TESTS'] == 'true');
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:test/test.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:mumbullet/src/audio/downloader.dart';
|
||||
import 'package:mumbullet/src/config/config.dart';
|
||||
import 'package:mumbullet/src/storage/database.dart';
|
||||
|
||||
void main() {
|
||||
group('YoutubeDownloader 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_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.001, // 1MB for testing
|
||||
);
|
||||
|
||||
// Initialize database
|
||||
database = DatabaseManager(tempDbPath);
|
||||
|
||||
// Create downloader
|
||||
downloader = YoutubeDownloader(config, database);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
// Clean up
|
||||
database.close();
|
||||
if (tempDir.existsSync()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
group('Valid YouTube URL Downloads', () {
|
||||
test('should download a valid YouTube video', () async {
|
||||
// Using a short, stable YouTube video for testing
|
||||
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'; // "Me at the zoo" - first YouTube video
|
||||
|
||||
final song = await downloader.download(testUrl);
|
||||
|
||||
// Verify song object
|
||||
expect(song.url, equals(testUrl));
|
||||
expect(song.title, isNotEmpty);
|
||||
expect(song.duration, greaterThan(0));
|
||||
expect(song.filePath, isNotEmpty);
|
||||
expect(song.id, greaterThan(0));
|
||||
|
||||
// Verify file exists (check all .wav files in cache directory)
|
||||
final cacheDir = Directory(tempDir.path);
|
||||
final wavFiles = cacheDir.listSync().where((f) => f.path.endsWith('.wav')).toList();
|
||||
expect(wavFiles.length, greaterThan(0));
|
||||
|
||||
// Find the actual downloaded file
|
||||
final actualFile = wavFiles.firstWhere((f) => File(f.path).existsSync());
|
||||
expect(File(actualFile.path).lengthSync(), greaterThan(0));
|
||||
|
||||
// Verify file is in cache directory
|
||||
expect(song.filePath, startsWith(tempDir.path));
|
||||
expect(song.filePath, endsWith('.wav'));
|
||||
}, timeout: Timeout(Duration(minutes: 2)));
|
||||
|
||||
test('should use cached version on second download', () async {
|
||||
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
|
||||
|
||||
// First download
|
||||
final stopwatch1 = Stopwatch()..start();
|
||||
final song1 = await downloader.download(testUrl);
|
||||
stopwatch1.stop();
|
||||
|
||||
// Second download (should use cache)
|
||||
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 (cached)
|
||||
expect(stopwatch2.elapsedMilliseconds, lessThan(stopwatch1.elapsedMilliseconds ~/ 2));
|
||||
|
||||
// File should still exist
|
||||
final file = File(song2.filePath);
|
||||
expect(file.existsSync(), isTrue);
|
||||
}, timeout: Timeout(Duration(minutes: 3)));
|
||||
|
||||
test('should handle different YouTube URL formats', () async {
|
||||
final testUrls = [
|
||||
'https://www.youtube.com/watch?v=jNQXAC9IVRw',
|
||||
'https://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, isNotEmpty);
|
||||
expect(song.duration, greaterThan(0));
|
||||
|
||||
final file = File(song.filePath);
|
||||
expect(file.existsSync(), isTrue);
|
||||
}
|
||||
|
||||
// All should have the same title (same video)
|
||||
final firstTitle = songs.first.title;
|
||||
for (final song in songs) {
|
||||
expect(song.title, equals(firstTitle));
|
||||
}
|
||||
}, timeout: Timeout(Duration(minutes: 5)));
|
||||
});
|
||||
|
||||
group('Invalid URL Handling', () {
|
||||
test('should throw exception for malformed URLs', () async {
|
||||
final invalidUrls = [
|
||||
'not-a-url',
|
||||
'https://invalid-domain.com/video',
|
||||
'https://youtube.com/invalid-path',
|
||||
'https://www.youtube.com/watch?v=',
|
||||
'',
|
||||
];
|
||||
|
||||
for (final url in invalidUrls) {
|
||||
expect(
|
||||
() => downloader.download(url),
|
||||
throwsException,
|
||||
reason: 'Should throw exception for invalid URL: $url',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('should throw exception for non-existent YouTube video', () async {
|
||||
// Using a clearly non-existent video ID
|
||||
const invalidUrl = 'https://www.youtube.com/watch?v=ThisVideoDoesNotExist123456789';
|
||||
|
||||
expect(
|
||||
() => downloader.download(invalidUrl),
|
||||
throwsException,
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw exception for private/deleted videos', () async {
|
||||
// This video ID is known to be private/deleted
|
||||
const privateUrl = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ_invalid';
|
||||
|
||||
expect(
|
||||
() => downloader.download(privateUrl),
|
||||
throwsException,
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle network timeouts gracefully', () async {
|
||||
// Using a URL that will likely timeout or fail
|
||||
const slowUrl = 'https://httpstat.us/200?sleep=30000'; // 30 second delay
|
||||
|
||||
expect(
|
||||
() => downloader.download(slowUrl),
|
||||
throwsException,
|
||||
);
|
||||
}, timeout: Timeout(Duration(seconds: 10)));
|
||||
});
|
||||
|
||||
group('Cache Management', () {
|
||||
test('should enforce cache size limits', () async {
|
||||
// Set very small cache limit
|
||||
final smallConfig = BotConfig(
|
||||
commandPrefix: '!',
|
||||
defaultPermissionLevel: 1,
|
||||
maxQueueSize: 10,
|
||||
cacheDirectory: tempDir.path,
|
||||
maxCacheSizeGb: 0.000001, // ~1KB - very small
|
||||
);
|
||||
|
||||
final smallCacheDownloader = YoutubeDownloader(smallConfig, database);
|
||||
|
||||
// Download a video (will exceed tiny cache limit)
|
||||
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
|
||||
final song = await smallCacheDownloader.download(testUrl);
|
||||
|
||||
// Verify file was created
|
||||
expect(File(song.filePath).existsSync(), isTrue);
|
||||
|
||||
// Cache enforcement should have run (no exception thrown)
|
||||
final stats = await smallCacheDownloader.getCacheStats();
|
||||
expect(stats['songCount'], greaterThanOrEqualTo(0));
|
||||
}, timeout: Timeout(Duration(minutes: 2)));
|
||||
|
||||
test('should clear cache completely', () async {
|
||||
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
|
||||
|
||||
// Download a video
|
||||
final song = await downloader.download(testUrl);
|
||||
expect(File(song.filePath).existsSync(), isTrue);
|
||||
|
||||
// Verify cache has content
|
||||
final statsBefore = await downloader.getCacheStats();
|
||||
expect(statsBefore['songCount'], greaterThan(0));
|
||||
|
||||
// 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(song.filePath).existsSync(), isFalse);
|
||||
}, timeout: Timeout(Duration(minutes: 2)));
|
||||
|
||||
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(0));
|
||||
expect(updatedStats['totalSizeMb'], greaterThanOrEqualTo(0));
|
||||
|
||||
// Verify file size matches
|
||||
final file = File(song.filePath);
|
||||
expect(updatedStats['totalSizeBytes'], equals(file.lengthSync()));
|
||||
}, 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);
|
||||
expect(File(song1.filePath).existsSync(), isTrue);
|
||||
|
||||
// Manually delete the file (simulating external deletion)
|
||||
await File(song1.filePath).delete();
|
||||
|
||||
// Try to download again - should re-download
|
||||
final song2 = await downloader.download(testUrl);
|
||||
|
||||
// Should create new file
|
||||
expect(File(song2.filePath).existsSync(), isTrue);
|
||||
expect(song2.title, equals(song1.title));
|
||||
|
||||
// May have different file path due to random naming
|
||||
expect(song2.filePath, isNotEmpty);
|
||||
}, timeout: Timeout(Duration(minutes: 3)));
|
||||
});
|
||||
|
||||
group('Error Recovery', () {
|
||||
test('should handle file system permission errors', () async {
|
||||
// Create a read-only cache directory
|
||||
final readOnlyDir = Directory(path.join(tempDir.path, 'readonly'));
|
||||
await readOnlyDir.create();
|
||||
|
||||
// Make directory read-only (Unix-like systems)
|
||||
if (!Platform.isWindows) {
|
||||
await Process.run('chmod', ['444', readOnlyDir.path]);
|
||||
}
|
||||
|
||||
final restrictedConfig = BotConfig(
|
||||
commandPrefix: '!',
|
||||
defaultPermissionLevel: 1,
|
||||
maxQueueSize: 10,
|
||||
cacheDirectory: readOnlyDir.path,
|
||||
maxCacheSizeGb: 1.0,
|
||||
);
|
||||
|
||||
final restrictedDownloader = YoutubeDownloader(restrictedConfig, database);
|
||||
|
||||
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
|
||||
|
||||
// Should handle permission error gracefully
|
||||
if (!Platform.isWindows) {
|
||||
expect(
|
||||
() => restrictedDownloader.download(testUrl),
|
||||
throwsException,
|
||||
);
|
||||
}
|
||||
|
||||
// Restore permissions for cleanup
|
||||
if (!Platform.isWindows) {
|
||||
await Process.run('chmod', ['755', readOnlyDir.path]);
|
||||
}
|
||||
}, timeout: Timeout(Duration(minutes: 2)));
|
||||
});
|
||||
|
||||
group('Metadata Extraction', () {
|
||||
test('should extract correct metadata from YouTube video', () async {
|
||||
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
|
||||
|
||||
final song = await downloader.download(testUrl);
|
||||
|
||||
// Verify metadata
|
||||
expect(song.title, isNotEmpty);
|
||||
expect(song.title, isNot(equals('Unknown Title')));
|
||||
expect(song.duration, greaterThan(0));
|
||||
expect(song.url, equals(testUrl));
|
||||
expect(song.addedAt, isNotNull);
|
||||
|
||||
// Title should be reasonable (not just the URL)
|
||||
expect(song.title.length, greaterThan(5));
|
||||
expect(song.title, isNot(contains('http')));
|
||||
}, timeout: Timeout(Duration(minutes: 2)));
|
||||
});
|
||||
}, skip: Platform.environment['SKIP_INTEGRATION_TESTS'] == 'true');
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('URL Extraction from HTML', () {
|
||||
|
||||
test('should extract URL from HTML anchor tag', () {
|
||||
// Access the private method through reflection or create a test helper
|
||||
// For now, let's test the functionality indirectly by testing the regex pattern
|
||||
final input = '<a href="https://www.youtube.com/watch?v=dpx6L15oA4k">https://www.youtube.com/watch?v=dpx6L15oA4k</a>';
|
||||
final aTagRegex = RegExp(r'<a\s+href="([^"]+)"[^>]*>.*?</a>', caseSensitive: false);
|
||||
final match = aTagRegex.firstMatch(input);
|
||||
|
||||
expect(match, isNotNull);
|
||||
expect(match!.group(1), equals('https://www.youtube.com/watch?v=dpx6L15oA4k'));
|
||||
});
|
||||
|
||||
test('should return original string if not HTML anchor tag', () {
|
||||
final input = 'https://www.youtube.com/watch?v=dpx6L15oA4k';
|
||||
final aTagRegex = RegExp(r'<a\s+href="([^"]+)"[^>]*>.*?</a>', caseSensitive: false);
|
||||
final match = aTagRegex.firstMatch(input);
|
||||
|
||||
expect(match, isNull);
|
||||
});
|
||||
|
||||
test('should handle various HTML anchor tag formats', () {
|
||||
final testCases = [
|
||||
'<a href="https://example.com">Link</a>',
|
||||
'<a href="https://example.com" target="_blank">Link</a>',
|
||||
'<A HREF="https://example.com">Link</A>',
|
||||
'<a href="https://example.com" class="link">Link Text</a>',
|
||||
];
|
||||
|
||||
final aTagRegex = RegExp(r'<a\s+href="([^"]+)"[^>]*>.*?</a>', caseSensitive: false);
|
||||
|
||||
for (final testCase in testCases) {
|
||||
final match = aTagRegex.firstMatch(testCase);
|
||||
expect(match, isNotNull, reason: 'Failed to match: $testCase');
|
||||
expect(match!.group(1), equals('https://example.com'));
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle empty or whitespace input', () {
|
||||
final testCases = ['', ' ', '\t\n'];
|
||||
final aTagRegex = RegExp(r'<a\s+href="([^"]+)"[^>]*>.*?</a>', caseSensitive: false);
|
||||
|
||||
for (final testCase in testCases) {
|
||||
final match = aTagRegex.firstMatch(testCase.trim());
|
||||
expect(match, isNull, reason: 'Should not match empty/whitespace: "$testCase"');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:mumbullet/src/config/config.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('AppConfig', () {
|
||||
late File tempConfigFile;
|
||||
|
||||
setUp(() {
|
||||
tempConfigFile = File('test_config.json');
|
||||
tempConfigFile.writeAsStringSync('''
|
||||
{
|
||||
"mumble": {
|
||||
"server": "test.server.com",
|
||||
"port": 12345,
|
||||
"username": "TestBot",
|
||||
"password": "testpass",
|
||||
"channel": "Root"
|
||||
},
|
||||
"bot": {
|
||||
"command_prefix": "?",
|
||||
"default_permission_level": 1,
|
||||
"max_queue_size": 100,
|
||||
"cache_directory": "./test_cache",
|
||||
"max_cache_size_gb": 2.5
|
||||
},
|
||||
"dashboard": {
|
||||
"port": 9090,
|
||||
"admin_username": "testadmin",
|
||||
"admin_password": "testpassword"
|
||||
}
|
||||
}
|
||||
''');
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
if (tempConfigFile.existsSync()) {
|
||||
tempConfigFile.deleteSync();
|
||||
}
|
||||
});
|
||||
|
||||
test('should load configuration from file', () {
|
||||
final config = AppConfig.fromFile(tempConfigFile.path);
|
||||
|
||||
expect(config.mumble.server, equals('test.server.com'));
|
||||
expect(config.mumble.port, equals(12345));
|
||||
expect(config.mumble.username, equals('TestBot'));
|
||||
expect(config.mumble.password, equals('testpass'));
|
||||
expect(config.mumble.channel, equals('TestChannel'));
|
||||
|
||||
expect(config.bot.commandPrefix, equals('?'));
|
||||
expect(config.bot.defaultPermissionLevel, equals(1));
|
||||
expect(config.bot.maxQueueSize, equals(100));
|
||||
expect(config.bot.cacheDirectory, equals('./test_cache'));
|
||||
expect(config.bot.maxCacheSizeGb, equals(2.5));
|
||||
|
||||
expect(config.dashboard.port, equals(9090));
|
||||
expect(config.dashboard.adminUsername, equals('testadmin'));
|
||||
expect(config.dashboard.adminPassword, equals('testpassword'));
|
||||
});
|
||||
|
||||
test('should throw exception for non-existent file', () {
|
||||
expect(() => AppConfig.fromFile('non_existent_file.json'), throwsA(isA<FileSystemException>()));
|
||||
});
|
||||
|
||||
test('should validate configuration', () {
|
||||
final config = AppConfig.fromFile(tempConfigFile.path);
|
||||
|
||||
// Valid configuration should not throw
|
||||
expect(() => config.validate(), returnsNormally);
|
||||
});
|
||||
});
|
||||
}
|
||||
Vendored
+71
@@ -0,0 +1,71 @@
|
||||
# Mumble server configuration file for testing
|
||||
|
||||
# Database configuration
|
||||
database=/data/mumble-server.sqlite
|
||||
|
||||
# Welcome message
|
||||
welcometext="Welcome to the MumBullet Test Server"
|
||||
|
||||
# Server password (empty for no password)
|
||||
serverpassword=testpass
|
||||
|
||||
# Maximum bandwidth (in bits per second) clients may use
|
||||
bandwidth=128000
|
||||
|
||||
# Maximum number of concurrent clients allowed
|
||||
users=100
|
||||
|
||||
# Regular expression used to validate channel names
|
||||
channelname=[ \\-=\\w\\#\\[\\]\\{\\}\\(\\)\\@\\|]+
|
||||
|
||||
# Regular expression used to validate user names
|
||||
username=[-=\\w\\[\\]\\{\\}\\(\\)\\@\\|\\.]+
|
||||
|
||||
# Maximum length of text messages in characters. 0 for no limit.
|
||||
textmessagelength=5000
|
||||
|
||||
# Allow HTML in messages
|
||||
allowhtml=true
|
||||
|
||||
# Set autoban duration for failed attempts, in seconds
|
||||
autobantime=300
|
||||
|
||||
# Set autoban attempts, set to 0 to disable
|
||||
autobancount=10
|
||||
|
||||
# Set autoban timeframe, in seconds
|
||||
autobantime=300
|
||||
|
||||
# Default channel when users join the server
|
||||
defaultchannel=0
|
||||
|
||||
# Enable bonjour service discovery
|
||||
bonjour=False
|
||||
|
||||
# Maximum length of channel name
|
||||
channellength=100
|
||||
|
||||
# Force clients to use certificate authentication
|
||||
certrequired=False
|
||||
|
||||
# Maximum number of channels allowed
|
||||
channelcount=100
|
||||
|
||||
# How deep the channel hierarchy can be
|
||||
channelnestinglimit=10
|
||||
|
||||
# Maximum number of channels to operate on in a single transaction
|
||||
channelfilterlinks=100
|
||||
|
||||
# Send voice to all channels the user is linked to
|
||||
allowping=true
|
||||
|
||||
# User message limit per second
|
||||
messagelimit=5
|
||||
|
||||
# Message burst limit per second
|
||||
messageburst=10
|
||||
|
||||
# Create a Music channel
|
||||
registerName=Music
|
||||
registerPassword=
|
||||
Vendored
+69
@@ -0,0 +1,69 @@
|
||||
# Mumble server configuration file for testing
|
||||
|
||||
# Basic server settings
|
||||
database=/var/lib/mumble-server/mumble-server.sqlite
|
||||
welcometext="<strong>Welcome to the MumBullet Test Server</strong><br/>This server is used for integration testing."
|
||||
host=0.0.0.0
|
||||
port=64738
|
||||
|
||||
# Server access
|
||||
serverpassword=serverpassword
|
||||
users=100
|
||||
allowhtml=true
|
||||
registerName=admin
|
||||
registerPassword=adminpassword
|
||||
|
||||
# Channels configuration
|
||||
# Root channel is always created automatically
|
||||
# These register channels on first run
|
||||
registerName=Music
|
||||
registerPassword=
|
||||
position=0
|
||||
|
||||
registerName=General
|
||||
registerPassword=
|
||||
position=1
|
||||
|
||||
registerName=Gaming
|
||||
registerPassword=
|
||||
position=2
|
||||
|
||||
# Create subchannels
|
||||
registerName=Music/Subroom1
|
||||
registerPassword=
|
||||
position=0
|
||||
|
||||
registerName=Music/Subroom2
|
||||
registerPassword=
|
||||
position=1
|
||||
|
||||
registerName=General/Subroom3
|
||||
registerPassword=
|
||||
position=0
|
||||
|
||||
# Default channel (Root channel is always id 0)
|
||||
defaultchannel=1 # Music channel
|
||||
|
||||
# User settings
|
||||
username=[-=\\w\\[\\]\\{\\}\\(\\)\\@\\|\\.]+
|
||||
textmessagelength=5000
|
||||
imagemessagelength=131072
|
||||
allowhtml=true
|
||||
|
||||
# Bandwidth settings
|
||||
bandwidth=128000
|
||||
|
||||
# Log settings
|
||||
logfile=
|
||||
|
||||
# Registration
|
||||
# Allow users to register themselves
|
||||
registerself=true
|
||||
# Don't require registration to join
|
||||
registerpassword=false
|
||||
|
||||
# Security
|
||||
# Automatically ban IPs with more than 10 failed attempts in 120 seconds for 300 seconds
|
||||
autobanAttempts=10
|
||||
autobanTimeframe=120
|
||||
autobanTime=300
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"mumble": {
|
||||
"server": "localhost",
|
||||
"port": 64738,
|
||||
"username": "TestBot",
|
||||
"password": "serverpassword",
|
||||
"channel": "Music"
|
||||
},
|
||||
"bot": {
|
||||
"command_prefix": "!",
|
||||
"default_permission_level": 0,
|
||||
"max_queue_size": 50,
|
||||
"cache_directory": "./test/fixtures/cache",
|
||||
"max_cache_size_gb": 1
|
||||
},
|
||||
"dashboard": {
|
||||
"port": 8081,
|
||||
"admin_username": "testadmin",
|
||||
"admin_password": "testpass"
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"mumble": {
|
||||
"server": "localhost",
|
||||
"port": 64738,
|
||||
"username": "AdminBot",
|
||||
"password": "serverpassword",
|
||||
"channel": "Music"
|
||||
},
|
||||
"bot": {
|
||||
"command_prefix": "!",
|
||||
"default_permission_level": 0,
|
||||
"max_queue_size": 50,
|
||||
"cache_directory": "./test/fixtures/cache",
|
||||
"max_cache_size_gb": 1
|
||||
},
|
||||
"dashboard": {
|
||||
"port": 8081,
|
||||
"admin_username": "testadmin",
|
||||
"admin_password": "testpass"
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"mumble": {
|
||||
"server": "localhost",
|
||||
"port": 64738,
|
||||
"username": "UserBot",
|
||||
"password": "serverpassword",
|
||||
"channel": "General"
|
||||
},
|
||||
"bot": {
|
||||
"command_prefix": "?",
|
||||
"default_permission_level": 1,
|
||||
"max_queue_size": 30,
|
||||
"cache_directory": "./test/fixtures/cache",
|
||||
"max_cache_size_gb": 0.5
|
||||
},
|
||||
"dashboard": {
|
||||
"port": 8082,
|
||||
"admin_username": "user",
|
||||
"admin_password": "userpass"
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
tags:
|
||||
docker:
|
||||
# Integration tests that require Docker to be running
|
||||
on_os:
|
||||
- linux
|
||||
- mac-os
|
||||
timeout: 60s
|
||||
@@ -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');
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:mumbullet/src/config/config.dart';
|
||||
import 'package:mumbullet/src/logging/logger.dart';
|
||||
import 'package:mumbullet/src/mumble/connection.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('Integration tests', () {
|
||||
late AppConfig config;
|
||||
|
||||
setUp(() {
|
||||
final configPath = 'test_config.json';
|
||||
final configFile = File(configPath);
|
||||
|
||||
if (!configFile.existsSync()) {
|
||||
configFile.writeAsStringSync('''
|
||||
{
|
||||
"mumble": {
|
||||
"server": "localhost",
|
||||
"port": 64738,
|
||||
"username": "TestBot",
|
||||
"password": "testpass",
|
||||
"channel": "Music"
|
||||
},
|
||||
"bot": {
|
||||
"command_prefix": "!",
|
||||
"default_permission_level": 0,
|
||||
"max_queue_size": 50,
|
||||
"cache_directory": "./test_cache",
|
||||
"max_cache_size_gb": 5
|
||||
},
|
||||
"dashboard": {
|
||||
"port": 8080,
|
||||
"admin_username": "admin",
|
||||
"admin_password": "changeme"
|
||||
}
|
||||
}
|
||||
''');
|
||||
}
|
||||
|
||||
config = AppConfig.fromFile(configPath);
|
||||
|
||||
// Set up logging
|
||||
final logManager = LogManager.getInstance();
|
||||
logManager.initialize(
|
||||
level: Level.INFO,
|
||||
logToConsole: true,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
final testConfigFile = File('test_config.json');
|
||||
if (testConfigFile.existsSync()) {
|
||||
testConfigFile.deleteSync();
|
||||
}
|
||||
});
|
||||
|
||||
test('Config validation passes', () {
|
||||
expect(() => config.validate(), returnsNormally);
|
||||
});
|
||||
|
||||
test('Mumble connection creation', () {
|
||||
final connection = MumbleConnection(config.mumble);
|
||||
expect(connection, isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,37 @@
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:mumbullet/mumbullet.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
test('calculate', () {
|
||||
expect(calculate(), 42);
|
||||
group('LogManager', () {
|
||||
test('should initialize with console logging', () {
|
||||
final logManager = LogManager.getInstance();
|
||||
|
||||
// This test just verifies that initialization doesn't throw
|
||||
expect(() => logManager.initialize(
|
||||
level: Level.INFO,
|
||||
logToConsole: true,
|
||||
), returnsNormally);
|
||||
|
||||
logManager.close();
|
||||
});
|
||||
|
||||
test('should log messages at different levels', () {
|
||||
final logManager = LogManager.getInstance();
|
||||
logManager.initialize(
|
||||
level: Level.ALL,
|
||||
logToConsole: true,
|
||||
);
|
||||
|
||||
// This test just verifies that logging doesn't throw
|
||||
expect(() {
|
||||
logManager.debug('Debug message');
|
||||
logManager.info('Info message');
|
||||
logManager.warning('Warning message');
|
||||
logManager.error('Error message');
|
||||
}, returnsNormally);
|
||||
|
||||
logManager.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user