// restructure: rewrite in step 1 (Connection + Identity + Presence): queue // `by` and adder-only keys follow the lasting client id. import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_server/server.dart'; import 'package:test/test.dart'; class _Peer { final WebSocket ws; final List> msgs = []; _Peer(this.ws) { ws.listen((m) => msgs.add(Map.from(jsonDecode('$m') as Map))); } static Future<_Peer> join(int port, String name) async { final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync'); final p = _Peer(ws); p.send(HelloMessage( proto: RelayConstants.protoVersion, secret: 's', user: HelloUser(name: name), media: const HelloMedia(name: '', size: 0, duration: 0)) .toJson()); await p.until((m) => m['t'] == 'welcome'); return p; } void send(Map json) => ws.add(jsonEncode(json)); Future> until(bool Function(Map m) test) async { final end = DateTime.now().add(const Duration(seconds: 5)); while (DateTime.now().isBefore(end)) { for (final m in msgs) { if (test(m)) { msgs.remove(m); return m; } } await Future.delayed(const Duration(milliseconds: 20)); } fail('nothing matched in $msgs'); } /// The next state whose queue passes [test]. Future state(bool Function(StateBroadcast s) test) async { final m = await until((m) => m['t'] == 'state' && test(StateBroadcast.fromJson(m))); return StateBroadcast.fromJson(m); } } void main() { late SyncServer server; setUp(() async { server = SyncServer(const AppConfig(port: 0, secret: 's', name: 't')); await server.start(); }); tearDown(() => server.stop()); test('queued items reach everyone; a server-fetched link stays on the server; a swap takes its item', () async { final a = await _Peer.join(server.boundPort, 'alice'); final b = await _Peer.join(server.boundPort, 'bob'); a.send(const QueueIntent.add(QueueItem( media: HelloMedia(name: 'https://example.com/v?token=secret', size: 0, duration: 0, url: 'https://example.com/v?token=secret'), how: QueueHow.server, title: 'A video', sub: 'Example')) .toJson()); a.send(const QueueIntent.add( QueueItem( media: HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://example.com/x.mp4'), how: QueueHow.direct, title: 'Direct'), next: true) .toJson()); final s = await b.state((s) => s.queue.length == 2); expect([for (final q in s.queue) q.title], ['Direct', 'A video']); final fetched = s.queue[1]; expect(fetched.byName, 'alice'); expect(jsonEncode(s.toJson()), isNot(contains('secret')), reason: 'the fetched URL is never broadcast'); expect(fetched.media.relay?.fetch, isTrue); // Bob puts the fetched item up without knowing its URL; the server fills it in. b.send(SwapIntent( media: const HelloMedia(name: 'A video', size: 0, duration: 0, relay: MediaRelay(fetch: true)), queueId: fetched.id) .toJson()); final after = await a.state((s) => s.media != null); expect(after.media?['name'], 'A video'); expect(after.queue.single.title, 'Direct'); final mediaId = after.mediaId; // A second swap of the same item (a race) is ignored. a.send(SwapIntent( media: const HelloMedia(name: 'A video', size: 0, duration: 0, relay: MediaRelay(fetch: true)), queueId: fetched.id) .toJson()); a.msgs.clear(); final later = await a.state((s) => true); expect(later.mediaId, mediaId); }); test('a bad add answers the sender; adder-only items need a key', () async { final a = await _Peer.join(server.boundPort, 'alice'); a.send(const QueueIntent.add(QueueItem( media: HelloMedia(name: 'f.mkv', size: 10, duration: 0), how: QueueHow.file, title: 'f')) .toJson()); final err = await a.until((m) => m['t'] == 'err'); expect('${err['message']}', contains('key')); a.send(const QueueIntent.add(QueueItem( media: HelloMedia(name: 'f.mkv', size: 10, duration: 0, url: 'file:///home/me/f.mkv'), how: QueueHow.file, title: 'f', key: 'k1234567890')) .toJson()); final s = await a.state((s) => s.queue.isNotEmpty); expect(s.queue.single.media.url, isNull, reason: 'an adder-only item carries no path or link'); expect(s.queue.single.key, 'k1234567890'); }); }