The shell (lib/src/shell/) replaces the Home and session screens:
- Party strip: profile, Library / Schedule / Settings tabs, Join and
Host cards (hosting no longer takes media), status-ring avatar stack
with the member list, chat & activity panel, Leave.
- The real session player is mounted once and shown full, as a
draggable corner-snapping mini player, or fullscreen (window_manager,
party and toasts overlaid); the Video is never mounted twice.
- Profile popover with inline rename, colour and a crop picker (256 px
PNG), pushed live to the session; avatar toasts; waiting pill.
- Motion kit (fastEaseInToSlowEaseOut, fade-through, popovers,
mouse-drag/wheel horizontal scrolling).
- Library = the existing picker for now (restyled next round); Schedule
= the new SchedulePage; Settings = left nav over the existing panels.
Protocol 9 (packages/core, packages/server, sync client):
- {"t":"profile"} renames / changes the picture mid-session ("rename",
"avatar" feed actions); names are cleaned the same way at hello.
- state.members carry avatarHash; pictures are served once from
/a/<sid>/<token>/<hash> (PNG/JPEG/WebP/GIF by magic bytes only) and
cached by hash in the app (AvatarCache).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
5.6 KiB
Dart
142 lines
5.6 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('kick unknown throws, kick known forbids + drops', () async {
|
|
final s = Session(id: 'default', secret: 'shh');
|
|
expect(() => s.kick('nope'), throwsA(isA<AuthException>()));
|
|
final t = s.issueToken('mallory', '');
|
|
var dropped = false;
|
|
String? gotReason;
|
|
s.tokens[t]!.onKick = (reason) async {
|
|
dropped = true;
|
|
gotReason = reason;
|
|
};
|
|
await s.kick(t, reason: 'idle');
|
|
expect(s.gate(t), MediaGate.forbidden);
|
|
expect(dropped, isTrue);
|
|
expect(gotReason, 'idle');
|
|
});
|
|
|
|
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 9: avatars by hash, profile intent', () {
|
|
expect(RelayConstants.protoVersion, 9);
|
|
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');
|
|
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('kicked + kick request round-trip', () {
|
|
const k = KickedMessage(reason: 'idle');
|
|
final back = KickedMessage.fromJson(Map<String, Object?>.from(k.toJson()));
|
|
expect(back.reason, 'idle');
|
|
const r = KickRequest(target: 'abc', reason: 'afk?');
|
|
final backR = KickRequest.fromJson(Map<String, Object?>.from(r.toJson()));
|
|
expect(backR.target, 'abc');
|
|
expect(backR.reason, 'afk?');
|
|
expect(() => KickedMessage.fromJson({'t': 'err'}), throwsA(isA<ProtocolException>()));
|
|
expect(() => KickRequest.fromJson({'t': 'err'}), 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);
|
|
});
|
|
});
|
|
}
|