From 73f9a7a76c0fde91d9ca0643025450e460b568ac Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Wed, 4 Oct 2023 14:45:19 +0100 Subject: [PATCH] internal_link: Parse internal links into narrows The core process on parsing internal links (here in `lib/model/internal_link.dart`) relied heavily on the existing code in the Zulip mobile app - from `src/utils/internalLinks.js`. In fact the `_parseStreamOperand` function here is a line for line port in order to capture the same semantics when processing streams. Where the implementation differs is this new process is less restrictive on the order of operator/operand pairs: supporting `#narrow/topic/_/stream_` where mobile only accepted `#narrow/stream/_/topic/_`. Also, the mobile implementation accepted as valid narrows DM operators with an email address as the operand (`#narrow/dm/a.40b.2Ecom.2Ec.2Ed.2Ecom`) but created an invalid narrow object (with NaNs for targets) whereas this implementation rejects them as invalid narrows. Likewise the test cases are also taken from the mobile code (`src/utils/__tests__/internalLinks-test.js`) and replicated here, save for the special narrow types (`#narrow/is/starred`) which are not yet implemented. --- lib/model/internal_link.dart | 177 +++++++++++ lib/model/internal_link.g.dart | 19 ++ test/model/internal_link_test.dart | 486 +++++++++++++++++++++++++++++ 3 files changed, 682 insertions(+) create mode 100644 lib/model/internal_link.g.dart create mode 100644 test/model/internal_link_test.dart diff --git a/lib/model/internal_link.dart b/lib/model/internal_link.dart index ef2684ae5ce..26ac1c8cb5e 100644 --- a/lib/model/internal_link.dart +++ b/lib/model/internal_link.dart @@ -1,9 +1,12 @@ import 'package:flutter/foundation.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 = { "%": ".", "(": ".28", @@ -106,3 +109,177 @@ Uri? tryResolveOnRealmUrl(String urlString, Uri realmUrl) { return null; } } + +/// A [Narrow] from a given URL, on `store`'s realm. +/// +/// `url` must already be passed through [tryResolveOnRealmUrl]. +/// +/// 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) +// TODO(#252): handle all valid narrow links, returning a search narrow +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) { + 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]); + if (negated) 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 = decodeHashComponent(operand); + if (topic == null) 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 = decodeHashComponent(operand); + if (streamName == null) 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 00000000000..7a6952c5d52 --- /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 00000000000..10a2ca69973 --- /dev/null +++ b/test/model/internal_link_test.dart @@ -0,0 +1,486 @@ + +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 '../example_data.dart' as eg; +import 'test_store.dart'; + +PerAccountStore setupStore({ + required Uri realmUrl, + List? streams, + List? users, +}) { + final account = eg.selfAccount.copyWith(realmUrl: realmUrl); + final store = eg.store(account: account); + if (streams != null) { + store.addStreams(streams); + } + store.addUser(eg.selfUser); + if (users != null) { + store.addUsers(users); + } + return store; +} + +void main() { + final realmUrl = Uri.parse('https://example.com/'); + + checkExpectedNarrows(List<(String, Narrow?)> testCases, { + List? streams, + PerAccountStore? store, + List? users, + }) { + assert((streams != null || users != null) ^ (store != null)); + if (store == null) { + streams ??= []; + store = setupStore(realmUrl: realmUrl, streams: streams, users: users); + } + for (final testCase in testCases) { + final String urlString = testCase.$1; + final Uri url = realmUrl.resolve(urlString); + final Narrow? expected = testCase.$2; + check(because: urlString, 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/')), + + // Dart's [Uri] currently lacks IDNA or Punycode support: + // https://github.com/dart-lang/sdk/issues/26284 + // https://github.com/dart-lang/sdk/issues/29420 + + // (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 description = testCase.$2; + final Uri url = testCase.$3; + final Uri realmUrl = testCase.$4; + final String testName = '${expected ? 'accept': 'reject'} $description: ${url.toString()}'; + test(testName, () { + final store = setupStore(realmUrl: realmUrl, streams: streams); + final result = parseInternalLink(url, store); + check(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', () { + final store = 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)), + ]; + checkExpectedNarrows(testCases, store: store); + }); + + test('/#narrow/stream/.../topic/... returns expected TopicNarrow', () { + final store = 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')), + ]; + checkExpectedNarrows(testCases, store: store); + }); + + test('/#narrow/dm/... returns expected DMNarrow', () { + final store = 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), + ]; + checkExpectedNarrows(testCases, store: store); + }); + + test('/#narrow/pm-with/... returns expected DMNarrow', () { + final store = 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), + ]; + checkExpectedNarrows(testCases, store: store); + }); + + // test('/#narrow/is/... returns expected Narrow', () { + // final store = setupStore(realmUrl, streams: streams); + // final testCases = [ + // ('/#narrow/is/dm', ...), + // ('/#narrow/is/private', ...), + // ('/#narrow/is/starred', ...), + // ('/#narrow/is/mentioned', ...), + // ]; + // checkExpectedNarrows(testCases, store: store); + // }); + + test('unexpected link shapes are rejected', () { + final store = 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), + ]; + 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')).isNull(); + check(decodeHashComponent('some.2gtext')).isNull(); + check(decodeHashComponent('some.arbitrary_text')).isNull(); + + // malformed UTF-8 + check(decodeHashComponent('.88.99.AA.BB')).isNull(); + }); + + test('parse correctly in stream and topic operands', () { + 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')), + ]; + checkExpectedNarrows(testCases, streams: streams); + }); + }); + + group('Stream link parsing edge cases', () { + final streams = [ + eg.stream(streamId: 1, name: "general"), + ]; + + test('basic', () { + final store = setupStore(realmUrl: realmUrl, streams: streams); + final testCases = [ + ('#narrow/stream/1-general', const StreamNarrow(1)), + ]; + checkExpectedNarrows(testCases, store: store); + }); + + test('if stream not found, use stream ID anyway', () { + final store = setupStore(realmUrl: realmUrl, streams: streams); + final testCases = [ + ('#narrow/stream/123-topic', const StreamNarrow(123)), + ]; + checkExpectedNarrows(testCases, store: store); + }); + + test('on stream link with wrong name, ID wins', () { + final store = setupStore(realmUrl: realmUrl, streams: streams); + final testCases = [ + ('#narrow/stream/1-nonsense', const StreamNarrow(1)), + ('#narrow/stream/1-', const StreamNarrow(1)), + ]; + checkExpectedNarrows(testCases, store: store); + }); + + test('on malformed stream link: reject', () { + final store = setupStore(realmUrl: realmUrl, streams: streams); + final testCases = [ + ('#narrow/stream/-1', null), + ('#narrow/stream/1nonsense-general', null), + ('#narrow/stream/-general', null), + ]; + checkExpectedNarrows(testCases, store: store); + }); + }); + + test('old stream links, for stream with hyphens or even looking like new-style', () { + 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)), + ]; + checkExpectedNarrows(testCases, streams: streams); + }); + + test('on ambiguous new- or old-style: new wins', () { + 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)), + ]; + checkExpectedNarrows(testCases, streams: streams); + }); + + test('on old stream link', () { + 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), + ]; + checkExpectedNarrows(testCases, streams: streams); + }); + + test('on old stream link, without realm info', () { + final stream = eg.stream(name: 'example'); + final testCases = [ + ('/#narrow/stream/${stream.name}/', StreamNarrow(stream.streamId)), + ('#narrow/stream/${stream.name}/', StreamNarrow(stream.streamId)), + ]; + checkExpectedNarrows(testCases, streams: [stream]); + }); + + group('Topic link parsing', () { + final stream = eg.stream(name: "example"); + + test('basic', () { + 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')), + ]; + checkExpectedNarrows(testCases, streams: [stream]); + }); + + test('on old topic link, with dot-encoding', () { + 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')), + ]; + checkExpectedNarrows(testCases, streams: [stream]); + }); + + test('on old topic link, without realm info', () { + final testCases = [ + ('/#narrow/stream/${stream.name}/topic/topic', + TopicNarrow(stream.streamId, 'topic')), + ('#narrow/stream/${stream.name}/topic/topic', + TopicNarrow(stream.streamId, 'topic')), + ]; + checkExpectedNarrows(testCases, streams: [stream]); + }); + }); + + group('DM link parsing', () { + final users = [ + eg.user(userId: 1), + eg.user(userId: 2), + ]; + + test('on group PM link', () { + 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), + ]; + checkExpectedNarrows(testCases, users: users); + }); + + test('on group PM link including self', () { + 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), + ]; + checkExpectedNarrows(testCases, users: users); + }); + }); + + // test('on a special link', () { + // final store = setupStore(realmUrl, streams: streams); + // final testCases = [ + // ('/#narrow/is/dm', ...), + // ('/#narrow/is/private', ...), + // ('/#narrow/is/starred', ...), + // ('/#narrow/is/mentioned', ...), + // ]; + // checkExpectedNarrows(testCases, store: store); + // }); + + // TODO(#82): near + // test('Links with near operators', () { + // 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')), + // ]; + // checkExpectedNarrows(testCases, streams: [stream], users: users); + // }); + + // TODO(#82): near + // test('Near operator extraction', () { + // 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), + // ]; + // }); +}