Files
sync_replay_project/packages/server/test/session_test.dart
T
foxyzandClaude Opus 5.5 6b10cae4e6 Protocol 12: Up next (a shared queue) and where the video comes from
Up next: a server-side queue anyone edits by item id (add at end or
next, move, remove, play now, skip); each item says who added it and
how it plays. At the real end the first item swaps in after the end
card's countdown, put up by whoever can (its adder for through-me links
and files, someone with access for Jellyfin, else anyone); items that
can't play are skipped with the reason. The next item's link is
resolved ahead. Greyed "Continues with" episodes below. A chip with the
controls opens it as a popover (leaf_widgets, local path dependency for
now); "Add to queue" next to Put it up everywhere.

Delivery: the server measures the source's rate, the media's bitrate
and each member's read rate; the members card shows a source line
(who passes it on, through what, keeping up or not, source left) and
each member's route and buffer. A slow source is told once per media.
A failed server fetch is announced to everyone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:40:26 +03:00

120 lines
4.9 KiB
Dart

import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:test/test.dart';
void main() {
group('Session model', () {
test('tokens are unique 128-bit hex', () {
final s = Session(id: 'default', secret: 'shh');
final a = s.issueToken('alice', '');
final b = s.issueToken('bob', '');
expect(a, hasLength(32));
expect(a, isNot(b));
expect(RegExp(r'^[0-9a-f]{32}$').hasMatch(a), isTrue);
});
test('gate: unknown -> unauthorized, known -> ok', () {
final s = Session(id: 'default', secret: 'shh');
expect(s.gate(null), MediaGate.unauthorized);
expect(s.gate('nope'), MediaGate.unauthorized);
final t = s.issueToken('alice', '');
expect(s.gate(t), MediaGate.ok);
});
test('oversize avatar rejected', () {
final s = Session(id: 'default', secret: 'shh');
final big = 'x' * (RelayConstants.maxAvatarBytes + 1);
expect(() => s.issueToken('alice', big), throwsA(isA<AuthException>()));
});
test('64 KiB avatar accepted under raised cap', () {
final s = Session(id: 'default', secret: 'shh');
final png = 'x' * (64 * 1024);
expect(s.issueToken('alice', png), isNotEmpty);
expect(s.tokens, hasLength(1));
});
test('names truncated to cap, blank becomes ?', () {
final s = Session(id: 'default', secret: 'shh');
final long = s.issueToken('a' * 100, '');
expect(s.tokens[long]!.name, hasLength(RelayConstants.maxNameLength));
final blank = s.issueToken(' ', '');
expect(s.tokens[blank]!.name, '?');
});
test('byte counters accumulate', () { final s = Session(id: 'default', secret: 'shh');
final a = s.issueToken('alice', '');
final b = s.issueToken('bob', '');
s.addBytes(a, 100);
s.addBytes(b, 50);
s.addBytes('ghost', 999);
expect(s.tokens[a]!.bytesServed, 100);
expect(s.totalBytesServed, 150);
});
test('SessionMap create/get/drop', () {
final m = SessionMap();
expect(m.get('default'), isNull);
final s = m.getOrCreate('default', 'shh');
expect(m.get('default'), same(s));
expect(m.getOrCreate('default', 'other'), same(s));
m.drop('default');
expect(m.get('default'), isNull);
});
});
group('core welcome + proto', () {
test('proto 12: avatars by hash, profile intent', () {
expect(RelayConstants.protoVersion, 12);
const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false);
expect(v.toJson().containsKey('avatarHash'), isFalse);
expect(v.toJson().containsKey('avatar'), isFalse);
const h = MemberView(
id: 'm1', name: 'a', avatarHash: 'abc', ready: false, inSet: false, buffered: 0, stale: false);
expect(MemberView.fromJson(Map<String, Object?>.from(h.toJson())).avatarHash, 'abc');
// host: only on the hosting app's member, absent otherwise.
expect(v.toJson().containsKey('host'), isFalse);
const host = MemberView(id: 'm2', name: 'h', ready: false, inSet: false, buffered: 0, stale: false, host: true);
expect(host.toJson()['host'], isTrue);
expect(MemberView.fromJson(Map<String, Object?>.from(host.toJson())).host, isTrue);
expect(MemberView.fromJson(Map<String, Object?>.from(v.toJson())).host, isFalse);
expect(const ProfileIntent(name: 'b').toJson(), {'t': 'profile', 'name': 'b'});
final p = ProfileIntent.fromJson({'t': 'profile', 'avatar': ''});
expect(p.name, isNull);
expect(p.avatar, '');
});
test('relayed media', () {
const m = HelloMedia(name: 'a', size: 1, duration: 0, relay: MediaRelay(source: 'm1', live: true));
final back = HelloMedia.fromJson(Map<String, Object?>.from(m.toJson()));
expect(back.relay!.source, 'm1');
expect(back.relay!.live, isTrue);
expect(back.relay!.fetch, isFalse);
expect(const MediaRelay().toJson(), isEmpty);
});
test('welcome round-trips with token + member id', () {
const w = WelcomeMessage(token: 'abc', memberId: 'm1', mediaUrl: 'http://h/m');
final back = WelcomeMessage.fromJson(Map<String, Object?>.from(w.toJson()));
expect(back.token, 'abc');
expect(back.memberId, 'm1');
expect(back.mediaUrl, 'http://h/m');
expect(back.state, isNull);
});
test('welcome rejects wrong type', () {
expect(() => WelcomeMessage.fromJson({'t': 'hello'}), throwsA(isA<ProtocolException>()));
});
test('lastSeen touch + stale helper', () {
final s = Session(id: 'default', secret: 'shh');
final t = s.issueToken('alice', '');
final viewer = s.tokens[t]!;
expect(viewer.isStale(const Duration(seconds: 30)), isFalse);
viewer.touch(DateTime.utc(2020, 1, 1));
expect(viewer.isStale(const Duration(seconds: 30)), isTrue);
expect(viewer.isStale(const Duration(seconds: 30), DateTime.utc(2020, 1, 1, 0, 0, 10)), isFalse);
});
});
}