diff --git a/lib/model/internal_link.dart b/lib/model/internal_link.dart index 79c4ea2f4a..5a19fbc547 100644 --- a/lib/model/internal_link.dart +++ b/lib/model/internal_link.dart @@ -1,8 +1,12 @@ -import 'store.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:json_annotation/json_annotation.dart'; import '../api/model/narrow.dart'; import 'narrow.dart'; +import 'store.dart'; + +part 'internal_link.g.dart'; const _hashReplacements = { "%": ".", @@ -20,6 +24,14 @@ String _encodeHashComponent(String str) { .replaceAllMapped(_encodeHashComponentRegex, (Match m) => _hashReplacements[m[0]!]!); } +/// Decode a dot-encoded string. +// The Zulip webapp uses this encoding in narrow-links: +// https://github.com/zulip/zulip/blob/1577662a6/static/js/hash_util.js#L18-L25 +@visibleForTesting +String decodeHashComponent(String str) { + return Uri.decodeComponent(str.replaceAll('.', '%')); +} + /// A URL to the given [Narrow], on `store`'s realm. /// /// To include /near/{messageId} in the link, pass a non-null [nearMessageId]. @@ -79,3 +91,192 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) { return store.account.realmUrl.replace(fragment: fragment.toString()); } + +/// A [Narrow] from a given URL, on `store`'s realm. +/// +/// Returns `null` if any of the operator/operand pairs are invalid. +/// +/// Since narrow links can combine operators in ways our [Narrow] type can't +/// represent, this can also return null for valid narrow links. +/// +/// This can also return null for some valid narrow links that our Narrow +/// type *could* accurately represent. We should try to understand these +/// better, but some kinds will be rare, even unheard-of: +/// #narrow/stream/1-announce/stream/1-announce (duplicated operator) +Narrow? parseInternalLink(Uri url, PerAccountStore store) { + if (!_isInternalLink(url, store.account.realmUrl)) return null; + + final (category, segments) = _getCategoryAndSegmentsFromFragment(url.fragment); + switch (category) { + case 'narrow': + if (segments.isEmpty || !segments.length.isEven) return null; + return _interpretNarrowSegments(segments, store); + } + return null; +} + +/// Check if `url` is an internal link on the given `realmUrl`. +bool _isInternalLink(Uri url, Uri realmUrl) { + if (url.hasScheme || url.hasAuthority || url.hasPort) { + // input that do not include scheme, host, and port + // are treated as on realm + try { + if (url.origin != realmUrl.origin) return false; + } on StateError { + return false; + } + } + return (url.hasEmptyPath || url.path == '/') + && !url.hasQuery + && url.hasFragment; +} + +/// Split `fragment` of arbitrary segments and handle trailing slashes +(String, List) _getCategoryAndSegmentsFromFragment(String fragment) { + final [category, ...segments] = fragment.split('/'); + if (segments.length > 1 && segments.last == '') segments.removeLast(); + return (category, segments); +} + +Narrow? _interpretNarrowSegments(List segments, PerAccountStore store) { + assert(segments.isNotEmpty); + assert(segments.length.isEven); + + ApiNarrowStream? streamElement; + ApiNarrowTopic? topicElement; + ApiNarrowDm? dmElement; + + for (var i = 0; i < segments.length; i += 2) { + final (operator, negated) = _parseOperator(segments[i]); + // TODO(#252): negated operators are not currently supported + // These should eventually return a SearchNarrow + if (negated == true) return null; + final operand = segments[i + 1]; + switch (operator) { + case _NarrowOperator.stream: + if (streamElement != null) return null; + final streamId = _parseStreamOperand(operand, store); + if (streamId == null) return null; + streamElement = ApiNarrowStream(streamId, negated: negated); + + case _NarrowOperator.topic: + case _NarrowOperator.subject: + if (topicElement != null) return null; + final String topic; + try { + topic = decodeHashComponent(operand); + } on ArgumentError { + return null; + } on FormatException { + return null; + } + topicElement = ApiNarrowTopic(topic, negated: negated); + + case _NarrowOperator.dm: + case _NarrowOperator.pmWith: + if (dmElement != null) return null; + final dmIds = _parseDmOperand(operand); + if (dmIds == null) return null; + dmElement = ApiNarrowDm(dmIds, negated: negated); + + case _NarrowOperator.near: + continue; // TODO(#82): support for near + + case _NarrowOperator.unknown: + return null; + } + } + + if (dmElement != null) { + if (streamElement != null || topicElement != null) return null; + return DmNarrow.withUsers(dmElement.operand, selfUserId: store.account.userId); + } else if (streamElement != null) { + final streamId = streamElement.operand; + if (topicElement != null) { + return TopicNarrow(streamId, topicElement.operand); + } else { + return StreamNarrow(streamId); + } + } + return null; +} + +@JsonEnum(fieldRename: FieldRename.kebab, alwaysCreate: true) +enum _NarrowOperator { + // 'dm' is new in server-7.0; means the same as 'pm-with' + dm, + near, + pmWith, + stream, + subject, + topic, + unknown; + + static _NarrowOperator fromRawString(String raw) => _byRawString[raw] ?? unknown; + + static final _byRawString = _$_NarrowOperatorEnumMap.map((key, value) => MapEntry(value, key)); +} + +(_NarrowOperator, bool) _parseOperator(String input) { + final String operator; + final bool negated; + if (input.startsWith('-')) { + operator = input.substring(1); + negated = true; + } else { + operator = input; + negated = false; + } + return (_NarrowOperator.fromRawString(operator), negated); +} + +/// Parse the operand of a `stream` operator, returning a stream ID. +/// +/// The ID might point to a stream that's hidden from our user (perhaps +/// doesn't exist). If so, most likely the user doesn't have permission to +/// see the stream's existence -- like with a guest user for any stream +/// they're not in, or any non-admin with a private stream they're not in. +/// Could be that whoever wrote the link just made something up. +/// +/// Returns null if the operand has an unexpected shape, or has the old shape +/// (stream name but no ID) and we don't know of a stream by the given name. +int? _parseStreamOperand(String operand, PerAccountStore store) { + // "New" (2018) format: ${stream_id}-${stream_name} . + final match = RegExp(r'^(\d+)(?:-.*)?$').firstMatch(operand); + final newFormatStreamId = (match != null) ? int.parse(match.group(1)!, radix:10) : null; + if (newFormatStreamId != null && store.streams.containsKey(newFormatStreamId)) { + return newFormatStreamId; + } + + // Old format: just stream name. This case is relevant indefinitely, + // so that links in old conversations continue to work. + final String streamName; + try { + streamName = decodeHashComponent(operand); + } on ArgumentError { + return null; + } on FormatException { + return null; + } + final stream = store.streamsByName[streamName]; + if (stream != null) return stream.streamId; + + if (newFormatStreamId != null) { + // Neither format found a stream, so it's hidden or doesn't exist. But + // at least we have a stream ID; give that to the caller. + return newFormatStreamId; + } + + // Unexpected shape, or the old shape and we don't know of a stream with + // the given name. + return null; +} + +List? _parseDmOperand(String operand) { + final rawIds = operand.split('-')[0].split(','); + try { + return rawIds.map((rawId) => int.parse(rawId, radix: 10)).toList(); + } on FormatException { + return null; + } +} diff --git a/lib/model/internal_link.g.dart b/lib/model/internal_link.g.dart new file mode 100644 index 0000000000..7a6952c5d5 --- /dev/null +++ b/lib/model/internal_link.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: constant_identifier_names, unnecessary_cast + +part of 'internal_link.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +const _$_NarrowOperatorEnumMap = { + _NarrowOperator.dm: 'dm', + _NarrowOperator.near: 'near', + _NarrowOperator.pmWith: 'pm-with', + _NarrowOperator.stream: 'stream', + _NarrowOperator.subject: 'subject', + _NarrowOperator.topic: 'topic', + _NarrowOperator.unknown: 'unknown', +}; diff --git a/test/model/internal_link_test.dart b/test/model/internal_link_test.dart new file mode 100644 index 0000000000..919159271d --- /dev/null +++ b/test/model/internal_link_test.dart @@ -0,0 +1,512 @@ + +import 'package:checks/checks.dart'; +import 'package:test/scaffolding.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/internal_link.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; + +import '../api/fake_api.dart'; +import '../example_data.dart' as eg; +import 'test_store.dart'; + +Future setupStore({ + required Uri realmUrl, + User? selfUser, + List? streams, + List? users, +}) async { + selfUser ??= eg.selfUser; + final account = Account( + id: 1001, + realmUrl: realmUrl, + email: selfUser.email, + userId: selfUser.userId, + apiKey: 'abcd', + zulipFeatureLevel: eg.recentZulipFeatureLevel, + zulipVersion: eg.recentZulipVersion, + zulipMergeBase: eg.recentZulipVersion, + ); + final initialSnapshot = eg.initialSnapshot(streams: streams ?? []); + final store = PerAccountStore.fromInitialSnapshot( + account: account, + connection: FakeApiConnection.fromAccount(account), + initialSnapshot: initialSnapshot, + ); + store.addUser(selfUser); + if (users != null) { + store.addUsers(users); + } + return store; +} + +void main() { + final realmUrl = Uri.parse('https://example.com/'); + + checkExpectedNarrows(testCases, { + List? streams, + PerAccountStore? store, + List? users, + bool resolveToRealm = true, + }) async { + assert((streams != null || users != null) ^ (store != null)); + if (store == null) { + streams ??= []; + store = await setupStore(realmUrl: realmUrl, streams: streams, users: users); + } + for (final testCase in testCases) { + final String path = testCase.$1; + final Uri url; + if (resolveToRealm) { + url = realmUrl.resolve(path); + } else { + url = Uri.parse(path); + } + final Narrow? expected = testCase.$2; + check(because: path, parseInternalLink(url, store)).equals(expected); + } + } + + group('Url acceptance', () { + final streams = [ + eg.stream(streamId: 1, name: 'check'), + ]; + final testCases = [ + (true, 'legacy: stream name, no ID', + realmUrl.resolve('#narrow/stream/check'), realmUrl), + (true, 'legacy: stream name, no ID, topic', + realmUrl.resolve('#narrow/stream/check/topic/topic1'), realmUrl), + + (true, 'with numeric stream ID', + realmUrl.resolve('#narrow/stream/123-check'), realmUrl), + (true, 'with numeric stream ID and topic', + realmUrl.resolve('#narrow/stream/123-check/topic/topic1'), realmUrl), + + (true, 'with numeric pm user IDs (new operator)', + realmUrl.resolve('#narrow/dm/123-mark'), realmUrl), + (true, 'with numeric pm user IDs (old operator)', + realmUrl.resolve('#narrow/pm-with/123-mark'), realmUrl), + + (false, 'wrong fragment', + realmUrl.resolve('#nope'), realmUrl), + (false, 'wrong path', + realmUrl.resolve('user_uploads/#narrow/stream/check'), realmUrl), + (false, 'wrong domain', + Uri.parse('https://another.com/#narrow/stream/check'), realmUrl), + + (false, '#narrowly', + realmUrl.resolve('#narrowly/stream/check'), realmUrl), + + (false, 'double slash', + Uri.parse('https://example.com//#narrow/stream/check'), realmUrl), + (false, 'triple slash', + Uri.parse('https://example.com///#narrow/stream/check'), realmUrl), + + (true, 'with port', + Uri.parse('https://example.com:444/#narrow/stream/check'), + Uri.parse('https://example.com:444')), + + // Uri.parse performs percent-encoding of unicode domains: + // `Uri.parse('https://example.भारत/')` has an origin of + // `https://example.%E0%A4%AD%E0%A4%BE%E0%A4%B0%E0%A4%A4` + // so in the following examples unicode domains will + // pass but punycode variants of unicode domains are not + // treated as matching. + + // (true, 'same domain, punycoded host', + // Uri.parse('https://example.xn--h2brj9c/#narrow/stream/check'), + // Uri.parse('https://example.भारत/')), // FAILS + + (true, 'punycodable host', + Uri.parse('https://example.भारत/#narrow/stream/check'), + Uri.parse('https://example.भारत/')), + + // (true, 'same domain, IDNA-mappable', + // Uri.parse('https://ℯⅩªm🄿ₗℰ.ℭᴼⓂ/#narrow/stream/check'), + // Uri.parse('https://example.com')), // FAILS + + (true, 'ipv4 address', + Uri.parse('http://192.168.0.1/#narrow/stream/check'), + Uri.parse('http://192.168.0.1/')), + + // (true, 'same IPv4 address, IDNA-mappable', + // Uri.parse('http://1𝟗𝟚。①⁶🯸.₀。𝟭/#narrow/stream/check'), + // Uri.parse('http://192.168.0.1/')), // FAILS + + // TODO: Add tests for IPv6. + + // These examples may seem weird, but a previous version of + // the zulip-mobile code accepted most of them. + + // This one, except possibly the fragment, is a 100% realistic link + // for innocent normal use. The buggy old version narrowly avoided + // accepting it... but would accept all the variations below. + (false, 'wrong domain, realm-like path, narrow-like fragment', + Uri.parse('https://web.archive.org/web/*/${realmUrl.resolve('#narrow/stream/check').toString()}'), + realmUrl), + (false, 'odd scheme, wrong domain, realm-like path, narrow-like fragment', + Uri.parse('ftp://web.archive.org/web/*/${realmUrl.resolve('#narrow/stream/check').toString()}'), + realmUrl), + (false, 'same domain, realm-like path, narrow-like fragment', + realmUrl.resolve('web/*/${realmUrl.resolve('#narrow/stream/check').toString()}'), + realmUrl), + ]; + for (final testCase in testCases) { + final bool expected = testCase.$1; + final String because = testCase.$2; + final Uri url = testCase.$3; + final Uri realmUrl = testCase.$4; + test(because, () async { + final store = await setupStore(realmUrl: realmUrl, streams: streams); + final result = parseInternalLink(url, store); + check(because: because, result != null).equals(expected); + }); + } + }); + + group('General link parsing', () { + final streams = [ + eg.stream(streamId: 1, name: 'check'), + eg.stream(streamId: 3, name: 'mobile'), + eg.stream(streamId: 5, name: 'stream'), + eg.stream(streamId: 123, name: 'topic'), + ]; + + test('/#narrow/stream/... returns expected StreamNarrow', () async { + final store = await setupStore(realmUrl: realmUrl, streams: streams); + final testCases = [ + ('/#narrow/stream/check', const StreamNarrow(1)), + ('/#narrow/stream/stream/', const StreamNarrow(5)), + ('/#narrow/stream/topic/', const StreamNarrow(123)), + ]; + await checkExpectedNarrows(testCases, store: store); + }); + + test('/#narrow/stream/.../topic/... returns expected TopicNarrow', () async { + final store = await setupStore(realmUrl: realmUrl, streams: streams); + final testCases = [ + ('/#narrow/stream/check/topic/test', + const TopicNarrow(1, 'test')), + ('/#narrow/stream/mobile/subject/topic/near/378333', + const TopicNarrow(3, 'topic')), // TODO(#82): near + ('/#narrow/stream/mobile/topic/topic/', + const TopicNarrow(3, 'topic')), + ('/#narrow/stream/stream/topic/topic/near/1', + const TopicNarrow(5, 'topic')), // TODO(#82): near + ('/#narrow/stream/stream/subject/topic/near/1', + const TopicNarrow(5, 'topic')), // TODO(#82): near + ('/#narrow/stream/stream/subject/topic', + const TopicNarrow(5, 'topic')), + ]; + await checkExpectedNarrows(testCases, store: store); + }); + + test('/#narrow/dm/... returns expected DMNarrow', () async { + final store = await setupStore(realmUrl: realmUrl, streams: streams); + final expectedNarrow = DmNarrow.withUsers([1, 2], + selfUserId: eg.selfUser.userId); + final testCases = [ + ('/#narrow/dm/1,2-group', expectedNarrow), + ('/#narrow/dm/1,2-group/near/1', expectedNarrow), // TODO(#82): near + ('/#narrow/dm/a.40b.2Ecom.2Ec.2Ed.2Ecom/near/3', null), + ]; + await checkExpectedNarrows(testCases, store: store); + }); + + test('/#narrow/pm-with/... returns expected DMNarrow', () async { + final store = await setupStore(realmUrl: realmUrl, streams: streams); + final expectedNarrow = DmNarrow.withUsers([1, 2], + selfUserId: eg.selfUser.userId); + final testCases = [ + ('/#narrow/pm-with/1,2-group', expectedNarrow), + ('/#narrow/pm-with/1,2-group/near/1', expectedNarrow), // TODO(#82): near + ('/#narrow/pm-with/a.40b.2Ecom.2Ec.2Ed.2Ecom/near/3', null), + ]; + await checkExpectedNarrows(testCases, store: store); + }); + + // test('/#narrow/is/... returns expected Narrow', () async { + // final store = await setupStore( + // realmUrl, selfUser: selfUser, streams: streams); + // final testCases = [ + // ('/#narrow/is/dm', ...), + // ('/#narrow/is/private', ...), + // ('/#narrow/is/starred', ...), + // ('/#narrow/is/mentioned', ...), + // ]; + // await checkExpectedNarrows(testCases, store: store); + // }); + + test('unexpected link shapes are rejected', () async { + final store = await setupStore(realmUrl: realmUrl, streams: streams); + final testCases = [ + ('/#narrow/stream/stream/topic/topic/near/', null), + // ('/#narrow/is/men', null), + // ('/#narrow/is/men/stream', null), + ('/#narrow/are/men/stream', null), + ]; + await checkExpectedNarrows(testCases, store: store); + }); + }); + + group('decodeHashComponent', () { + test('correctly decode MediaWiki-style dot-encoded strings', () { + check(decodeHashComponent('some_text')).equals('some_text'); + check(decodeHashComponent('some.20text')).equals('some text'); + check(decodeHashComponent('some.2Etext')).equals('some.text'); + + check(decodeHashComponent('na.C3.AFvet.C3.A9')).equals('naïveté'); + check(decodeHashComponent('.C2.AF.5C_(.E3.83.84)_.2F.C2.AF')).equals('¯\\_(ツ)_/¯'); + + // malformed dot-encoding + check(() => decodeHashComponent('some.text')).throws(); + check(() => decodeHashComponent('some.2gtext')).throws(); + check(() => decodeHashComponent('some.arbitrary_text')).throws(); + + // malformed UTF-8 + check(() => decodeHashComponent('.88.99.AA.BB')).throws(); + }); + + test('parse correctly in stream and topic operands', () async { + final streams = [ + eg.stream(streamId: 1, name: 'some_stream'), + eg.stream(streamId: 2, name: 'some stream'), + eg.stream(streamId: 3, name: 'some.stream'), + ]; + final testCases = [ + ('/#narrow/stream/some_stream', + const StreamNarrow(1)), + ('/#narrow/stream/some.20stream', + const StreamNarrow(2)), + ('/#narrow/stream/some.2Estream', + const StreamNarrow(3)), + ('/#narrow/stream/some_stream/topic/some_topic', + const TopicNarrow(1, 'some_topic')), + ('/#narrow/stream/some_stream/topic/some.20topic', + const TopicNarrow(1, 'some topic')), + ('/#narrow/stream/some_stream/topic/some.2Etopic', + const TopicNarrow(1, 'some.topic')), + ]; + await checkExpectedNarrows(testCases, streams: streams); + }); + }); + + group('Stream link parsing edge cases', () { + final streams = [ + eg.stream(streamId: 1, name: "general"), + ]; + + test('basic', () async { + final store = await setupStore(realmUrl: realmUrl, streams: streams); + final testCases = [ + ('#narrow/stream/1-general', const StreamNarrow(1)), + ]; + await checkExpectedNarrows(testCases, store: store); + }); + + test('if stream not found, use stream ID anyway', () async { + final store = await setupStore(realmUrl: realmUrl, streams: streams); + final testCases = [ + ('#narrow/stream/123-topic', const StreamNarrow(123)), + ]; + await checkExpectedNarrows(testCases, store: store); + }); + + test('on stream link with wrong name, ID wins', () async { + final store = await setupStore(realmUrl: realmUrl, streams: streams); + final testCases = [ + ('#narrow/stream/1-nonsense', const StreamNarrow(1)), + ('#narrow/stream/1-', const StreamNarrow(1)), + ]; + await checkExpectedNarrows(testCases, store: store); + }); + + test('on malformed stream link: reject', () async { + final store = await setupStore(realmUrl: realmUrl, streams: streams); + final testCases = [ + ('#narrow/stream/-1', null), + ('#narrow/stream/1nonsense-general', null), + ('#narrow/stream/-general', null), + ]; + await checkExpectedNarrows(testCases, store: store); + }); + }); + + test('old stream links, for stream with hyphens or even looking like new-style', () async { + final streams = [ + eg.stream(streamId: 1, name: 'test-team'), + eg.stream(streamId: 2, name: '311'), + eg.stream(streamId: 3, name: '311-'), + eg.stream(streamId: 4, name: '311-help'), + eg.stream(streamId: 5, name: '--help'), + ]; + final testCases = [ + ('#narrow/stream/test-team/', const StreamNarrow(1)), + ('#narrow/stream/311/', const StreamNarrow(2)), + ('#narrow/stream/311-/', const StreamNarrow(3)), + ('#narrow/stream/311-help/', const StreamNarrow(4)), + ('#narrow/stream/--help/', const StreamNarrow(5)), + ]; + await checkExpectedNarrows(testCases, streams: streams); + }); + + test('on ambiguous new- or old-style: new wins', () async { + final streams = [ + eg.stream(streamId: 1, name: '311'), + eg.stream(streamId: 2, name: '311-'), + eg.stream(streamId: 3, name: '311-help'), + eg.stream(streamId: 311, name: 'collider'), + ]; + final testCases = [ + ('#narrow/stream/311/', const StreamNarrow(311)), + ('#narrow/stream/311-/', const StreamNarrow(311)), + ('#narrow/stream/311-help/', const StreamNarrow(311)), + ]; + await checkExpectedNarrows(testCases, streams: streams); + }); + + test('on old stream link', () async { + final streams = [ + eg.stream(streamId: 1, name: 'check'), + eg.stream(streamId: 2, name: 'bot testing'), + eg.stream(streamId: 3, name: 'check.API'), + eg.stream(streamId: 4, name: 'stream'), + eg.stream(streamId: 5, name: 'topic'), + ]; + final testCases = [ + ('#narrow/stream/check/', const StreamNarrow(1)), + ('#narrow/stream/bot.20testing/', const StreamNarrow(2)), + ('#narrow/stream/check.2EAPI/', const StreamNarrow(3)), + ('#narrow/stream/stream/', const StreamNarrow(4)), + ('#narrow/stream/topic/', const StreamNarrow(5)), + + ('#narrow/stream/check.API/', null), + ]; + await checkExpectedNarrows(testCases, streams: streams); + }); + + test('on old stream link, without realm info', () async { + final stream = eg.stream(name: 'example'); + final testCases = [ + ('/#narrow/stream/${stream.name}/', StreamNarrow(stream.streamId)), + ('#narrow/stream/${stream.name}/', StreamNarrow(stream.streamId)), + ]; + await checkExpectedNarrows(testCases, streams: [stream], resolveToRealm: false); + }); + + group('Topic link parsing', () { + final stream = eg.stream(name: "example"); + + test('basic', () async { + String mkUrl(operand) { + return '#narrow/stream/${stream.streamId}-general/topic/$operand'; + } + final testCases = [ + (mkUrl('(no.20topic)'), TopicNarrow(stream.streamId, '(no topic)')), + (mkUrl('lunch'), TopicNarrow(stream.streamId, 'lunch')), + ]; + await checkExpectedNarrows(testCases, streams: [stream]); + }); + + test('on old topic link, with dot-encoding', () async { + String mkUrl(operand) { + return '#narrow/stream/${stream.name}/topic/$operand'; + } + final testCases = [ + (mkUrl('(no.20topic)'), TopicNarrow(stream.streamId, '(no topic)')), + (mkUrl('google.2Ecom'), TopicNarrow(stream.streamId, 'google.com')), + (mkUrl('google.com'), null), + (mkUrl('topic.20name'), TopicNarrow(stream.streamId, 'topic name')), + (mkUrl('stream'), TopicNarrow(stream.streamId, 'stream')), + (mkUrl('topic'), TopicNarrow(stream.streamId, 'topic')), + ]; + await checkExpectedNarrows(testCases, streams: [stream]); + }); + + test('on old topic link, without realm info', () async { + final testCases = [ + ('/#narrow/stream/${stream.name}/topic/topic', + TopicNarrow(stream.streamId, 'topic')), + ('#narrow/stream/${stream.name}/topic/topic', + TopicNarrow(stream.streamId, 'topic')), + ]; + await checkExpectedNarrows(testCases, + streams: [stream], + resolveToRealm: false); + }); + }); + + group('DM link parsing', () { + final users = [ + eg.user(userId: 1), + eg.user(userId: 2), + ]; + + test('on group PM link', () async { + final expectedNarrow = DmNarrow.withUsers( + users.map((e) => e.userId).toList(), + selfUserId: eg.selfUser.userId); + final testCases = [ + ('#narrow/dm/1,2-group', expectedNarrow), + ('#narrow/pm-with/1,2-group', expectedNarrow), + ]; + await checkExpectedNarrows(testCases, users: users); + }); + + test('on group PM link including self', () async { + final expectedNarrow = DmNarrow.withUsers( + users.map((e) => e.userId).toList(), + selfUserId: eg.selfUser.userId); + final testCases = [ + ('#narrow/dm/1,2,${eg.selfUser.userId}-group', expectedNarrow), + ('#narrow/pm-with/1,2,${eg.selfUser.userId}-group', expectedNarrow), + ]; + await checkExpectedNarrows(testCases, users: users); + }); + }); + + // test('on a special link', () async { + // final store = await setupStore( + // realmUrl, selfUser: selfUser, streams: streams); + // final testCases = [ + // ('/#narrow/is/dm', ...), + // ('/#narrow/is/private', ...), + // ('/#narrow/is/starred', ...), + // ('/#narrow/is/mentioned', ...), + // ]; + // await checkExpectedNarrows(testCases, store: store); + // }); + + // TODO(#82): near + // test('Links with near operators', () async { + // final stream = eg.stream(); + // final users = [ + // eg.user(userId: 1), + // eg.user(userId: 2), + // ]; + // final expectedNarrow = DmNarrow.withUsers( + // users.map((e) => e.userId).toList(), + // selfUserId: eg.selfUser.userId); + // final testCases = [ + // ('#narrow/dm/1,2-group/near/2', expectedNarrow), + // ('#narrow/pm-with/1,2-group/near/2', expectedNarrow), + // ('#narrow/stream/${stream.name}/topic/test/near/1', + // TopicNarrow(stream.streamId, 'test')), + // ('#narrow/stream/${stream.name}/subject/test/near/1', + // TopicNarrow(stream.streamId, 'test')), + // ]; + // await checkExpectedNarrows(testCases, streams: [stream], users: users); + // }); + + // TODO(#82): near + // test('Near operator extraction', () async { + // final testCases = [ + // ('/#narrow/is/private', null), + // ('/#narrow/stream/check', null), + // ('/#narrow/near/1', 1), + // ('/#narrow/dm/1,3-group/near/1/', 1), + // ('/#narrow/pm-with/1,3-group/near/1/', 1), + // ('/#narrow/stream/jest/topic/test/near/1', 1), + // ]; + // }); +}