Major architecture update: stable identity, component validation, media indexing, and protocol docs
This commit is contained in:
1 parent
ce7c97d13a
commit
01c110bbe1
16 files changed
+2718
-468
No files matched your search
+19
-47
@@ -2,34 +2,6 @@ import 'package:chat_server/src/handlers/auth_handlers.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('parseUserId', () {
|
||||
test('parses valid userId', () {
|
||||
final parsed = parseUserId('alice@example.com');
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.username, 'alice');
|
||||
expect(parsed.host, 'example.com');
|
||||
});
|
||||
|
||||
test('returns null for invalid userId', () {
|
||||
expect(parseUserId('alice'), isNull);
|
||||
expect(parseUserId('alice@'), isNull);
|
||||
expect(parseUserId('@example.com'), isNull);
|
||||
expect(parseUserId('alice@example.com@x'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('defaultSchemeForHost', () {
|
||||
test('returns http for localhost-style hosts', () {
|
||||
expect(defaultSchemeForHost('localhost:9870'), 'http');
|
||||
expect(defaultSchemeForHost('127.0.0.1:9870'), 'http');
|
||||
expect(defaultSchemeForHost('[::1]:9870'), 'http');
|
||||
});
|
||||
|
||||
test('returns https for non-local hosts', () {
|
||||
expect(defaultSchemeForHost('chat.example.com'), 'https');
|
||||
});
|
||||
});
|
||||
|
||||
group('normalizeHomeServer', () {
|
||||
test('normalizes host without scheme', () {
|
||||
expect(
|
||||
@@ -59,30 +31,30 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('homeServerMatchesUserIdHost', () {
|
||||
test('matches host and explicit port', () {
|
||||
group('defaultSchemeForHost', () {
|
||||
test('returns http for localhost-style hosts', () {
|
||||
expect(defaultSchemeForHost('localhost:9870'), 'http');
|
||||
expect(defaultSchemeForHost('127.0.0.1:9870'), 'http');
|
||||
expect(defaultSchemeForHost('[::1]:9870'), 'http');
|
||||
});
|
||||
|
||||
test('returns https for non-local hosts', () {
|
||||
expect(defaultSchemeForHost('chat.example.com'), 'https');
|
||||
});
|
||||
});
|
||||
|
||||
group('toUserHandle', () {
|
||||
test('uses URL host and port', () {
|
||||
expect(
|
||||
homeServerMatchesUserIdHost(
|
||||
'https://chat.example.com:8443', 'chat.example.com:8443'),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
homeServerMatchesUserIdHost(
|
||||
'https://chat.example.com:8443', 'chat.example.com'),
|
||||
isFalse,
|
||||
toUserHandle('alice', 'https://chat.example.com:8443'),
|
||||
'@alice@chat.example.com:8443',
|
||||
);
|
||||
});
|
||||
|
||||
test('matches default ports when omitted', () {
|
||||
test('falls back to raw home server string', () {
|
||||
expect(
|
||||
homeServerMatchesUserIdHost(
|
||||
'https://chat.example.com', 'chat.example.com:443'),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
homeServerMatchesUserIdHost(
|
||||
'http://chat.example.com', 'chat.example.com:80'),
|
||||
isTrue,
|
||||
toUserHandle('alice', 'chat.example.com'),
|
||||
'@alice@chat.example.com',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:chat_server/config/config.dart';
|
||||
import 'package:chat_server/server.dart';
|
||||
import 'package:chat_server/src/state/app_state.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
final repoDir = Directory.current.path;
|
||||
final configPath = '$repoDir${Platform.pathSeparator}config.yaml';
|
||||
final objectboxDir = '$repoDir${Platform.pathSeparator}objectbox';
|
||||
final uploadsDir = '$repoDir${Platform.pathSeparator}uploads';
|
||||
|
||||
late String originalConfig;
|
||||
late Uri baseUri;
|
||||
final random = Random();
|
||||
|
||||
Future<void> waitUntilServerReady(Uri uri) async {
|
||||
final client = HttpClient();
|
||||
try {
|
||||
for (var i = 0; i < 120; i++) {
|
||||
try {
|
||||
final req = await client.getUrl(uri.resolve('/.well-known/server'));
|
||||
final res = await req.close();
|
||||
if (res.statusCode == 200) {
|
||||
await res.drain();
|
||||
return;
|
||||
}
|
||||
await res.drain();
|
||||
} catch (_) {}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
}
|
||||
fail('Server did not become ready in time.');
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
setUpAll(() async {
|
||||
originalConfig = File(configPath).existsSync() ? File(configPath).readAsStringSync() : '';
|
||||
|
||||
final bindPort = 12000 + random.nextInt(2000);
|
||||
final testConfig = '''
|
||||
server:
|
||||
name: "Reliquary Test Server"
|
||||
bind_host: "127.0.0.1"
|
||||
bind_port: $bindPort
|
||||
public_base_url: "http://127.0.0.1:$bindPort"
|
||||
configured: true
|
||||
first_setup: true
|
||||
bootstrap_admin_limit: 2
|
||||
dev_db_tools: true
|
||||
|
||||
security:
|
||||
jwt_secret: "integration_test_secret_1234567890"
|
||||
''';
|
||||
File(configPath).writeAsStringSync(testConfig);
|
||||
baseUri = Uri.parse('http://127.0.0.1:$bindPort');
|
||||
|
||||
final dataMdb = File('$objectboxDir${Platform.pathSeparator}data.mdb');
|
||||
final lockMdb = File('$objectboxDir${Platform.pathSeparator}lock.mdb');
|
||||
if (dataMdb.existsSync()) {
|
||||
dataMdb.deleteSync();
|
||||
}
|
||||
if (lockMdb.existsSync()) {
|
||||
lockMdb.deleteSync();
|
||||
}
|
||||
final uploads = Directory(uploadsDir);
|
||||
if (uploads.existsSync()) {
|
||||
for (final entity in uploads.listSync()) {
|
||||
if (entity is File) {
|
||||
entity.deleteSync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Config.initialize();
|
||||
initializeStore();
|
||||
unawaited(startServer());
|
||||
await waitUntilServerReady(baseUri);
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
await stopServer();
|
||||
if (originalConfig.isEmpty) {
|
||||
File(configPath).deleteSync();
|
||||
} else {
|
||||
File(configPath).writeAsStringSync(originalConfig);
|
||||
}
|
||||
});
|
||||
|
||||
test('auth + upload + component messages + gallery index', () async {
|
||||
final client = HttpClient();
|
||||
try {
|
||||
Future<Map<String, dynamic>> postJson(
|
||||
String path,
|
||||
Map<String, dynamic> body, {
|
||||
String? token,
|
||||
}) async {
|
||||
final req = await client.postUrl(baseUri.resolve(path));
|
||||
req.headers.set(HttpHeaders.contentTypeHeader, 'application/json');
|
||||
if (token != null) {
|
||||
req.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token');
|
||||
}
|
||||
req.write(jsonEncode(body));
|
||||
final res = await req.close();
|
||||
final text = await utf8.decodeStream(res);
|
||||
expect(res.statusCode, anyOf(200, 201), reason: '$path -> $text');
|
||||
return jsonDecode(text) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getJson(
|
||||
String path, {
|
||||
String? token,
|
||||
}) async {
|
||||
final req = await client.getUrl(baseUri.resolve(path));
|
||||
if (token != null) {
|
||||
req.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token');
|
||||
}
|
||||
final res = await req.close();
|
||||
final text = await utf8.decodeStream(res);
|
||||
expect(res.statusCode, 200, reason: '$path -> $text');
|
||||
return jsonDecode(text) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> uploadFile(
|
||||
String filePath, {
|
||||
required String token,
|
||||
}) async {
|
||||
final file = File(filePath);
|
||||
expect(file.existsSync(), isTrue, reason: 'Missing test asset: $filePath');
|
||||
final bytes = file.readAsBytesSync();
|
||||
final rawFileName = file.uri.pathSegments.last;
|
||||
final safeFileName = rawFileName.replaceAll(RegExp(r'[^a-zA-Z0-9._-]'), '_');
|
||||
final lower = rawFileName.toLowerCase();
|
||||
final contentType = lower.endsWith('.mp4')
|
||||
? 'video/mp4'
|
||||
: lower.endsWith('.png')
|
||||
? 'image/png'
|
||||
: lower.endsWith('.jpg') || lower.endsWith('.jpeg')
|
||||
? 'image/jpeg'
|
||||
: lower.endsWith('.gif')
|
||||
? 'image/gif'
|
||||
: 'application/octet-stream';
|
||||
final boundary = '----reliquary-test-boundary';
|
||||
final req = await client.postUrl(baseUri.resolve('/api/uploads'));
|
||||
req.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token');
|
||||
req.headers.set(
|
||||
HttpHeaders.contentTypeHeader,
|
||||
'multipart/form-data; boundary=$boundary',
|
||||
);
|
||||
req.write('--$boundary\r\n');
|
||||
req.write(
|
||||
'Content-Disposition: form-data; name="file"; filename="$safeFileName"\r\n',
|
||||
);
|
||||
req.write('Content-Type: $contentType\r\n\r\n');
|
||||
req.add(bytes);
|
||||
req.write('\r\n--$boundary--\r\n');
|
||||
final res = await req.close();
|
||||
final text = await utf8.decodeStream(res);
|
||||
expect(res.statusCode, 200, reason: '/api/uploads -> $text');
|
||||
return jsonDecode(text) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
final register = await postJson('/auth/register', {
|
||||
'username': 'alice',
|
||||
'password': 'password123',
|
||||
});
|
||||
final token = register['token'] as String;
|
||||
|
||||
final channel = await postJson(
|
||||
'/api/channels',
|
||||
{'name': 'integration-channel', 'description': 'integration'},
|
||||
token: token,
|
||||
);
|
||||
final channelId = channel['id'] as int;
|
||||
|
||||
final uploadedPng = await uploadFile(
|
||||
'$repoDir${Platform.pathSeparator}scripts${Platform.pathSeparator}media${Platform.pathSeparator}Untitled-1.png',
|
||||
token: token,
|
||||
);
|
||||
final pngAttachmentId = uploadedPng['attachmentId'] as String;
|
||||
|
||||
final uploadedPsd = await uploadFile(
|
||||
'$repoDir${Platform.pathSeparator}scripts${Platform.pathSeparator}media${Platform.pathSeparator}ftnr-symbol.psd',
|
||||
token: token,
|
||||
);
|
||||
final psdAttachmentId = uploadedPsd['attachmentId'] as String;
|
||||
expect(psdAttachmentId, isNotEmpty);
|
||||
|
||||
final uploadedMp4 = await uploadFile(
|
||||
'$repoDir${Platform.pathSeparator}scripts${Platform.pathSeparator}media${Platform.pathSeparator}Zelsus: Enemy Within [jUKjVrDFUug].mp4',
|
||||
token: token,
|
||||
);
|
||||
final mp4AttachmentId = uploadedMp4['attachmentId'] as String;
|
||||
expect(mp4AttachmentId, isNotEmpty);
|
||||
|
||||
final localMessage = await postJson(
|
||||
'/api/messages',
|
||||
{
|
||||
'channelId': channelId,
|
||||
'components': [
|
||||
{
|
||||
'id': 'cmp_local_gallery',
|
||||
'type': 'gallery',
|
||||
'typeVersion': 1,
|
||||
'fallback': 'Local upload gallery',
|
||||
'data': {
|
||||
'items': [
|
||||
{
|
||||
'id': 'itm_local_1',
|
||||
'source': 'local_upload',
|
||||
'attachmentId': pngAttachmentId,
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
token: token,
|
||||
);
|
||||
expect(localMessage['componentsJson'], contains('"source":"local_upload"'));
|
||||
expect(localMessage['componentsJson'], contains('"status":"ready"'));
|
||||
|
||||
final fileAndVideoMessage = await postJson(
|
||||
'/api/messages',
|
||||
{
|
||||
'channelId': channelId,
|
||||
'components': [
|
||||
{
|
||||
'id': 'cmp_files',
|
||||
'type': 'gallery',
|
||||
'typeVersion': 1,
|
||||
'fallback': 'Local file + local video + external file link',
|
||||
'data': {
|
||||
'items': [
|
||||
{
|
||||
'id': 'itm_psd',
|
||||
'source': 'local_upload',
|
||||
'attachmentId': psdAttachmentId,
|
||||
'caption': 'PSD unsupported preview candidate'
|
||||
},
|
||||
{'id': 'itm_mp4', 'source': 'local_upload', 'attachmentId': mp4AttachmentId, 'caption': 'MP4 playback candidate'},
|
||||
{
|
||||
'id': 'itm_ext_file',
|
||||
'source': 'external_url',
|
||||
'url': 'https://example.com/assets/archive.zip',
|
||||
'mimeType': 'application/zip',
|
||||
'originalFileName': 'archive.zip'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
token: token,
|
||||
);
|
||||
expect(fileAndVideoMessage['componentsJson'], contains('"itm_psd"'));
|
||||
expect(fileAndVideoMessage['componentsJson'], contains('"itm_mp4"'));
|
||||
expect(fileAndVideoMessage['componentsJson'], contains('"external_url"'));
|
||||
|
||||
final externalMessage = await postJson(
|
||||
'/api/messages',
|
||||
{
|
||||
'channelId': channelId,
|
||||
'components': [
|
||||
{
|
||||
'id': 'cmp_external_gallery',
|
||||
'type': 'gallery',
|
||||
'typeVersion': 1,
|
||||
'fallback': 'External gallery',
|
||||
'data': {
|
||||
'items': [
|
||||
{
|
||||
'id': 'itm_ext_1',
|
||||
'source': 'external_url',
|
||||
'url': 'https://pbs.twimg.com/media/HCI8WPKaYAAKhkj?format=jpg&name=medium',
|
||||
'mimeType': 'image/jpeg',
|
||||
'originalFileName': 'HCI8WPKaYAAKhkj.jpg',
|
||||
},
|
||||
{
|
||||
'id': 'itm_ext_2',
|
||||
'source': 'external_url',
|
||||
'url': 'https://pbs.twimg.com/media/HCKAMLxaUAAhYJS?format=jpg&name=medium',
|
||||
'mimeType': 'image/jpeg',
|
||||
'originalFileName': 'HCKAMLxaUAAhYJS.jpg',
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
token: token,
|
||||
);
|
||||
expect(externalMessage['componentsJson'], contains('"source":"external_url"'));
|
||||
expect(externalMessage['componentsJson'], contains('"status":"ready"'));
|
||||
|
||||
final messages = await getJson('/api/messages?channelId=$channelId&limit=10', token: token);
|
||||
final messageList = (messages['messages'] as List).cast<Map<String, dynamic>>();
|
||||
expect(messageList.length, greaterThanOrEqualTo(2));
|
||||
|
||||
final media = await getJson('/api/channels/$channelId/media?limit=10', token: token);
|
||||
final mediaItems = (media['media'] as List).cast<Map<String, dynamic>>();
|
||||
expect(mediaItems.length, greaterThanOrEqualTo(6));
|
||||
|
||||
final hasExternal = mediaItems.any((m) => (m['externalUrl'] as String?)?.contains('pbs.twimg.com/media/HCI8WPKaYAAKhkj') == true);
|
||||
final hasPngLocal = mediaItems.any((m) => (m['attachmentId'] as String?) == pngAttachmentId);
|
||||
final hasPsdLocal = mediaItems.any((m) => (m['attachmentId'] as String?) == psdAttachmentId);
|
||||
final hasMp4Local = mediaItems.any((m) => (m['attachmentId'] as String?) == mp4AttachmentId);
|
||||
final hasExternalFile = mediaItems.any((m) => (m['externalUrl'] as String?) == 'https://example.com/assets/archive.zip');
|
||||
final hasVideoKind = mediaItems.any((m) => (m['attachmentId'] as String?) == mp4AttachmentId && m['kind'] == 'video');
|
||||
|
||||
expect(hasExternal, isTrue);
|
||||
expect(hasPngLocal, isTrue);
|
||||
expect(hasPsdLocal, isTrue);
|
||||
expect(hasMp4Local, isTrue);
|
||||
expect(hasExternalFile, isTrue);
|
||||
expect(hasVideoKind, isTrue);
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user