import 'dart:convert'; import 'dart:io'; import 'package:pipe/pipe.dart'; import 'package:pipe/src/cli.dart'; import 'package:test/test.dart'; const String fixturePath = 'test/fixtures/firestore_queries'; const String adapterConfigPath = '$fixturePath/adapters.json'; void main() { late Project project; late ResolutionRun run; late FirestoreModel model; setUpAll(() async { project = await loadProject(fixturePath); run = await resolveProject(project, concurrency: 1); model = buildFirestoreModel( project, run.outcomes, adapters: parseAdapterConfig(File(adapterConfigPath).readAsStringSync()), ); }); tearDownAll(() async { await run.dispose(); }); List byMember(String member, {String? collection}) => [ for (final query in model.queries) if (query.member == member && (collection == null || query.collection == collection)) query, ]; QueryRecord single(String member, {String? collection}) { final hits = byMember(member, collection: collection); expect( hits, hasLength(1), reason: '$member/${collection ?? '*'}: ${hits.map((q) => q.site)}', ); return hits.single; } List ops(QueryRecord query) => [ for (final filter in query.filters) '${filter.field} ${filter.op}', ]; test('fixture resolves with zero analyzer errors', () { // test/fixtures/** has its own analysis_options, so `dart analyze` in the // repo does not vouch for the fixture; the analyzer's own diagnostics do. final errors = []; for (final outcome in run.outcomes) { expect(outcome.resolved, isTrue, reason: outcome.failure); for (final diagnostic in outcome.diagnostics) { if (diagnostic.severity.name != 'error') continue; errors.add('${outcome.path}: ${diagnostic.message}'); } } expect(errors, isEmpty, reason: errors.join('\n')); expect(model.firestoreResolved, isTrue); expect(model.packageDeclared, isTrue); }); group('direct chains', () { test('equality plus orderBy is exact', () { final query = single('equalityAndOrder'); expect(query.scope, 'collection'); expect(query.collection, 'categories'); expect(query.pathPattern, 'categories'); expect(ops(query), ['isActive ==']); expect(query.filters.single.valueType, 'bool'); expect(query.orderBy.single.toJson(), { 'field': 'name', 'dir': 'asc', 'conditional': false, }); expect(query.limit.kind, 'none'); expect(query.realtime, isFalse); expect(query.confidence, 'exact'); expect(query.origin, 'chain'); expect(query.owner, 'DirectQueries'); expect(query.reasons, isEmpty); }); test('every named operator maps to its Firestore operator', () { final query = single('everyOperator'); expect(ops(query), [ 'ownerId ==', 'status !=', 'price <', 'price <=', 'createdAt >', 'rank >=', 'tags array-contains', 'genres array-contains-any', 'city in', 'kind not-in', 'deletedAt ==', 'publishedAt !=', ]); final types = { for (final filter in query.filters) '${filter.field} ${filter.op}': filter.valueType, }; expect(types['ownerId =='], 'string'); expect(types['price <'], 'int'); expect(types['price <='], 'double'); expect(types['createdAt >'], 'timestamp'); expect(types['genres array-contains-any'], 'list'); expect(types['deletedAt =='], 'null'); expect(types['publishedAt !='], 'null'); final counts = { for (final filter in query.filters) if (filter.inCount != null) filter.field: filter.inCount, }; expect(counts, {'genres': 2, 'city': 3, 'kind': 1}); expect(query.limit.toJson(), { 'kind': 'literal', 'value': 20, 'conditional': false, }); }); test('two operators in one where() are two filters', () { final query = single('rangeInOneCall'); expect(ops(query), ['startsAt >=', 'startsAt <']); expect(query.filters.map((f) => f.valueType), everyElement('timestamp')); expect(query.orderBy.single.dir, 'desc'); }); test('collection names resolve through constants', () { final names = byMember('constantNames').map((q) => q.collection); expect(names, ['orders', 'auditTrail', 'reviews']); final audit = single('constantNames', collection: 'auditTrail'); expect(audit.limit.last, isTrue); expect(audit.limit.value, 5); expect(byMember('constantNames').map((q) => q.confidence), [ 'exact', 'exact', 'exact', ]); }); test('interpolated and helper-built paths keep dynamic document ids', () { final products = single('interpolatedPath'); expect(products.pathPattern, 'stores/*/products'); expect(products.collection, 'products'); expect(products.confidence, 'exact'); final payouts = single('pathHelpers', collection: 'payouts'); expect(payouts.pathPattern, 'stores/*/payouts'); final drafts = single('pathHelpers', collection: 'drafts'); expect(drafts.pathPattern, 'users/*/drafts'); }); test('doc() then collection() builds a subcollection path', () { final services = single('documentThenSubcollection'); expect(services.pathPattern, 'stores/*/services'); expect(services.collection, 'services'); expect(services.cursor, isTrue); final saved = single('rootDocumentPath'); expect(saved.pathPattern, 'users/*/saved'); }); test('collectionGroup with snapshots is a realtime group query', () { final query = single('groupStream'); expect(query.scope, 'collectionGroup'); expect(query.collection, 'userSubscriptions'); expect(query.realtime, isTrue); }); test('FieldPath.documentId is __name__, FieldPath([...]) is dotted', () { final query = single('fieldPaths'); expect(ops(query), ['__name__ ==', 'address.city ==']); expect(query.orderBy.single.field, '__name__'); expect(query.confidence, 'exact'); }); test('Filter.or / Filter.and become a composite node', () { final query = single('disjunction'); expect(query.filters, isEmpty); expect(query.composite!.toJson(), { 'op': 'or', 'filters': [ { 'field': 'buyerId', 'op': '==', 'conditional': false, 'valueType': 'string', }, { 'composite': { 'op': 'and', 'filters': [ { 'field': 'sellerId', 'op': '==', 'conditional': false, 'valueType': 'string', }, { 'field': 'open', 'op': '==', 'conditional': false, 'valueType': 'bool', }, ], }, }, ], }); }); test('count() and aggregate(sum) are aggregation queries', () { final queries = byMember('counting'); expect(queries.map((q) => q.aggregate), ['count', 'sum']); expect(ops(queries.first), ['storeId ==']); }); test('withConverter passes the query through', () { final query = single('converted'); expect(query.collection, 'tips'); expect(ops(query), ['amount >']); }); test('a fully dynamic collection is partial with no collection', () { final query = single('dynamicCollection'); expect(query.collection, isNull); expect(query.pathPattern, '*'); expect(query.confidence, 'partial'); expect( query.reasons, contains('collection name is not a compile-time constant'), ); expect(ops(query), ['a ==']); }); test('document reads are counted, never listed', () { expect(byMember('documentRead'), isEmpty); expect(model.documentReads, 1); expect(model.notes.join(), contains('1 document read')); }); }); group('name validation', () { test('names outside the safe set are replaced, with a reason', () { final query = single('unsafeNames'); expect(query.pathPattern, '*'); expect(query.collection, isNull); expect(query.filters.single.field, '*'); expect(query.confidence, 'partial'); expect( query.reasons, containsAll([ 'a field name has characters outside the safe set', 'a path segment has characters outside the safe set', ]), ); }); test('no source text beyond safe names reaches the JSON', () { final json = jsonEncode([for (final q in model.queries) q.toJson()]); expect(json, isNot(contains('ignore previous'))); expect(json, isNot(contains('a b'))); for (final query in model.queries) { final names = [ ?query.collection, ?query.pathPattern, for (final filter in query.filters) filter.field, for (final order in query.orderBy) order.field, ]; for (final name in names) { expect(safeNamePattern.hasMatch(name), isTrue, reason: name); } } }); }); group('built queries', () { test('a variable-built query unions its clauses; ifs make them ' 'conditional', () { final query = single('streamUserNotifications'); expect(query.collection, 'notifications'); expect(query.origin, 'variable'); expect(ops(query), ['userId ==']); expect(query.filters.single.conditional, isFalse); expect(query.orderBy.single.toJson(), { 'field': 'timestamp', 'dir': 'desc', 'conditional': false, }); expect(query.limit.toJson(), {'kind': 'variable', 'conditional': true}); expect(query.cursor, isTrue); expect(query.realtime, isTrue); expect(query.confidence, 'exact'); }); test('an if around both assignment and terminal is not conditional, and ' 'a later assignment does not reach an earlier terminal', () { final queries = byMember('platform'); expect(queries, hasLength(2)); final inside = queries.first; final after = queries.last; expect(ops(inside), ['userId ==', 'isActive ==']); expect(inside.filters.every((f) => !f.conditional), isTrue); expect(inside.orderBy, isEmpty); expect(ops(after), ['userId ==', 'isActive ==']); expect(after.filters.last.conditional, isTrue); expect(after.orderBy.single.conditional, isFalse); }); test('ternary alternatives are both recorded as conditional', () { final query = single('ternaryClause'); expect(query.orderBy.map((o) => '${o.dir} ${o.conditional}'), [ 'desc true', 'asc true', ]); expect(query.filters.single.conditional, isFalse); }); test('fields are followed through constructor and late initializers', () { expect( single('receipts', collection: 'notificationReceipts').origin, 'variable', ); expect(single('receipts', collection: 'inbox').limit.value, 3); }); test('a helper is inlined with its call-site arguments', () { final query = single('listingPage'); expect(query.scope, 'collectionGroup'); expect(query.collection, 'products'); expect(ops(query), ['userId ==']); expect(query.orderBy.single.dir, 'desc'); expect(query.limit.kind, 'variable'); expect(query.confidence, 'exact'); final stores = single('countActiveStores'); expect(stores.origin, 'helper'); expect(stores.aggregate, 'count'); expect(ops(stores), ['active ==']); }); test('helpers are inlined one level deep only', () { final query = single('nestedHelper'); expect(query.confidence, 'unknown'); expect(query.origin, 'unknown'); expect( query.reasons, contains('query helper nested more than one level deep'), ); }); test('a query from a parameter is unknown but keeps its local clauses', () { final query = single('fromParameter'); expect(query.confidence, 'unknown'); expect(query.origin, 'unknown'); expect(query.scope, isNull); expect(query.collection, isNull); expect(ops(query), ['visible ==']); expect(query.limit.value, 10); expect(query.reasons, contains('query arrives as a parameter')); final fallback = single('fallback'); expect(fallback.confidence, 'unknown'); expect(ops(fallback), ['kind ==']); expect(fallback.filters.single.conditional, isTrue); }); test('inLoop covers loops and per-element closures, not a fixed list', () { final loops = { for (final query in byMember('inLoops')) query.collection: query.inLoop, }; expect(loops, { 'items': true, 'products': true, 'audit': true, 'once': false, }); expect(single('equalityAndOrder').inLoop, isFalse); }); }); test('look-alike names outside cloud_firestore are never extracted', () { // Iterable.where/take, a project class named Query/CollectionReference and // a `collection()` on a non-Firestore class all share method names with // the Firestore API. Receivers are matched by type and library, not name. expect( model.queries.where((q) => q.path == 'lib/negative_controls.dart'), isEmpty, ); expect(model.queries.where((q) => q.member == 'lookalikes'), isEmpty); }); group('adapters', () { List adapted(String member) => [ for (final query in byMember(member)) if (query.origin == 'adapter') query, ]; test('a collection group with an in filter', () { final query = adapted('reconcile').single; expect(query.scope, 'collectionGroup'); expect(query.collection, 'settlements'); expect(ops(query), ['status in']); expect(query.filters.single.valueType, 'list'); expect(query.limit.value, 200); expect(query.realtime, isFalse); expect(query.confidence, 'exact'); }); test('equality lists, named range bounds and orderBy objects', () { final query = adapted('digest').single; expect(ops(query), [ 'userId ==', 'isRead ==', 'createdAt >=', 'createdAt <', ]); expect(query.filters[1].valueType, 'bool'); expect(query.filters[2].valueType, 'timestamp'); expect(query.orderBy.single.toJson(), { 'field': 'createdAt', 'dir': 'desc', 'conditional': false, }); }); test('a local list built with add() is followed; if makes it ' 'conditional', () { final query = adapted('followers').single; expect(ops(query), ['active ==', 'following array-contains']); expect(query.filters.first.conditional, isTrue); expect(query.filters.last.conditional, isFalse); expect(query.orderBy.single.field, '__name__'); expect(query.cursor, isTrue); expect(query.limit.value, 500); expect(query.confidence, 'exact'); }); test('an enum operator argument maps through opMap', () { final query = adapted('storeScoped').single; expect(query.pathPattern, 'stores/*/payouts'); expect(ops(query), ['amount >']); expect(query.filters.single.valueType, 'int'); }); test('a transaction method on a second interface is matched', () { final query = adapted('confirm').single; expect(query.collection, 'confirmations'); expect(ops(query), ['token ==']); expect(query.limit.value, 1); }); test('calls through an implementing class match, in a loop', () { final query = adapted('concrete').single; expect(query.collection, 'carts'); expect(query.inLoop, isTrue); }); test('an unmapped method is not a query', () { expect(byMember('notAQuery'), isEmpty); }); test('invalid configs are refused with a clear message', () { expect(() => parseAdapterConfig('{}'), throwsA(isA())); expect( () => parseAdapterConfig( '{"adapters":[{"interface":"G","library":"package:a/a.dart",' '"methods":{"q":{"scope":"table"}}}]}', ), throwsA( isA().having( (e) => e.toString(), 'message', contains('scope'), ), ), ); expect( () => parseAdapterConfig( '{"adapters":[{"interface":"G","library":"package:a/a.dart",' '"methods":{"q":{"scope":"collection"}},' '"types":{"W":{"field":0,"op":"LIKE"}}}]}', ), throwsA( isA().having( (e) => e.toString(), 'message', contains('LIKE'), ), ), ); // YAML is accepted too. final yaml = parseAdapterConfig(''' adapters: - interface: G library: package:a/a.dart methods: q: {scope: collectionGroup, path: 0} '''); expect(yaml.single.methods['q']!.scope, 'collectionGroup'); }); }); test( 'the shared pipeline carries the inventory alongside the findings', () async { final analysis = await analyzeProject(project, concurrency: 1); try { expect(analysis.firestore.queries, isNotEmpty); // No adapters were passed, so no adapter records. expect( analysis.firestore.queries.where((q) => q.origin == 'adapter'), isEmpty, ); } finally { await analysis.dispose(); } }, ); test('queries are sorted by site', () { final sites = [for (final query in model.queries) (query.path, query.line)]; final sorted = [...sites] ..sort((a, b) { final byPath = a.$1.compareTo(b.$1); return byPath != 0 ? byPath : a.$2.compareTo(b.$2); }); expect(sites, sorted); }); test('the report root never carries an absolute path', () { expect(queriesRoot('/work/app', cwd: '/work'), 'app'); expect(queriesRoot('/work', cwd: '/work'), '.'); expect(queriesRoot('/elsewhere/app', cwd: '/work'), 'app'); }); group('pipe queries CLI', () { Future pipe(List arguments) => Process.run( Platform.resolvedExecutable, ['bin/pipe.dart', 'queries', ...arguments, '-c', '1'], ); test('--format json keeps stdout pure JSON; notes go to stderr', () async { final result = await pipe([ fixturePath, '--format', 'json', '--adapter-config', adapterConfigPath, ]); expect(result.exitCode, 0, reason: '${result.stderr}'); final json = jsonDecode(result.stdout as String) as Map; expect(json['version'], 1); expect(json['root'], fixturePath); expect((json['coverage'] as Map)['complete'], isTrue); final totals = json['totals'] as Map; final queries = json['queries'] as List; expect(totals['queries'], queries.length); expect(totals['byConfidence'], isA>()); expect((totals['byOrigin'] as Map)['adapter'], 6); expect(json['unresolvedFirestoreUris'], isEmpty); expect(result.stderr as String, contains('document read')); }); test('--format md groups queries by collection', () async { final result = await pipe([fixturePath, '--format', 'md']); expect(result.exitCode, 0, reason: '${result.stderr}'); final text = result.stdout as String; expect(text, startsWith('# Firestore queries')); expect(text, contains('Coverage: ')); expect(text, contains('## `notifications` (')); expect(text, contains('## `(unresolved)` (')); expect(text, isNot(contains('ignore previous'))); // Unresolved queries are listed last. expect( text.indexOf('## `(unresolved)`'), greaterThan(text.indexOf('## `userSubscriptions`')), ); }); test('a project without cloud_firestore says so, and ' '--require-firestore fails', () async { final plain = await pipe(['test/fixtures/stuck_loading']); expect(plain.exitCode, 0, reason: '${plain.stderr}'); final json = jsonDecode(plain.stdout as String) as Map; expect(json['queries'], isEmpty); expect( (json['notes'] as List).join(), contains('cloud_firestore is not in the package graph'), ); final strict = await pipe([ 'test/fixtures/stuck_loading', '--require-firestore', ]); expect(strict.exitCode, 2); expect(strict.stderr as String, contains('--require-firestore')); }); test('a directory inside the package is a scope, not a project', () async { // `pipe queries lib` looked for lib/.dart_tool/package_config.json and // failed. The graph is the package's; the path only narrows the scan. final result = await pipe(['$fixturePath/lib/data']); expect(result.exitCode, 0, reason: '${result.stderr}'); final json = jsonDecode(result.stdout as String) as Map; expect(json['root'], fixturePath); final coverage = json['coverage'] as Map; expect(coverage['discovered'], 1); expect(coverage['complete'], isTrue); // lib/data holds only the gateway, so the queries the rest of lib issues // are out of scope. final sites = [ for (final query in json['queries'] as List) (query as Map)['site'] as String, ]; expect(sites, everyElement(startsWith('lib/data/'))); expect(model.queries, isNotEmpty); expect(json['unresolvedFirestoreUris'], isEmpty); }); test('--dir under a scoped path is relative to that path', () async { final result = await pipe(['$fixturePath/lib', '--dir', 'data']); expect(result.exitCode, 0, reason: '${result.stderr}'); final json = jsonDecode(result.stdout as String) as Map; expect((json['coverage'] as Map)['discovered'], 1); }); test('an unresolved package is reported at its root, not the scope', () { final copy = Directory.systemTemp.createTempSync('pipe-queries-fresh'); try { File('${copy.path}/pubspec.yaml').writeAsStringSync('name: fresh\n'); File('${copy.path}/lib/fresh.dart') ..createSync(recursive: true) ..writeAsStringSync('void main() {}\n'); final root = copy.resolveSymbolicLinksSync(); expect( () => PipeRunner().run(['queries', '$root/lib']), throwsA( isA() .having((e) => e.message, 'message', contains('package graph')) .having( (e) => e.remedy, 'remedy', allOf( endsWith('pub get` in $root'), isNot(contains('$root/lib')), ), ), ), ); } finally { copy.deleteSync(recursive: true); } }); }); }