diff --git a/test/e2e/04_nosql_workspaces/e2e_mongo_index_manager_test.dart b/test/e2e/04_nosql_workspaces/e2e_mongo_index_manager_test.dart new file mode 100644 index 00000000..02a88ce0 --- /dev/null +++ b/test/e2e/04_nosql_workspaces/e2e_mongo_index_manager_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/features/mongodb/mongo_index_info.dart'; +import 'package:querya_desktop/features/mongodb/mongo_indexes_view.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +const _timeout = Timeout(Duration(seconds: 60)); + +void main() { + final connection = MongoConnection(id: 1, name: 'e2e', host: 'localhost'); + + const indexes = [ + MongoIndexInfo(name: '_id_', keys: {'_id': 1}, sizeBytes: 16384), + MongoIndexInfo( + name: 'email_1', keys: {'email': 1}, isUnique: true, sizeBytes: 8192), + ]; + + Future open( + WidgetTester tester, { + Future Function({ + required Map keys, + String? name, + bool unique, + bool sparse, + int? expireAfterSeconds, + })? onCreateIndex, + Future Function(String name)? onDropIndex, + }) async { + await tester.binding.setSurfaceSize(const material.Size(1200, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + queryaThemeTestShell( + child: MongoIndexesView( + connection: connection, + database: 'shop', + collection: 'orders', + initialIndexes: indexes, + onCreateIndex: onCreateIndex, + onDropIndex: onDropIndex, + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('Create Index builds a compound index from the indexes tab', + timeout: _timeout, (tester) async { + Map? created; + bool? createdUnique; + await open( + tester, + onCreateIndex: ({ + required keys, + name, + unique = false, + sparse = false, + expireAfterSeconds, + }) async { + created = keys; + createdUnique = unique; + }, + ); + + expect(find.text('Indexes: orders'), findsOneWidget); + expect(find.text('2 indexes'), findsOneWidget); + + await tester.tap(find.widgetWithText(PrimaryButton, 'Create Index')); + await tester.pumpAndSettle(); + expect(find.text('Collection: orders'), findsOneWidget); + + await tester.enterText(find.byType(material.TextField).first, 'category'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add Field (Compound)')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(material.TextField).at(1), 'price'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Unique')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(PrimaryButton, 'Create Index').last); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 5)); + + expect(created, {'category': 1, 'price': 1}); + expect(createdUnique, isTrue); + }); + + testWidgets('dropping asks for confirmation and Cancel keeps the index', + timeout: _timeout, (tester) async { + String? dropped; + await open(tester, onDropIndex: (name) async => dropped = name); + + final dropButtons = find.byIcon(material.Icons.delete_outline_rounded); + expect(dropButtons, findsOneWidget); // `_id_` is not droppable + + await tester.tap(dropButtons); + await tester.pumpAndSettle(); + expect(find.byType(QueryaConfirmDialog), findsOneWidget); + expect( + find.textContaining('Are you sure you want to drop index "email_1"'), + findsOneWidget, + ); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(dropped, isNull); + expect(find.text('email_1'), findsOneWidget); + expect(find.text('2 indexes'), findsOneWidget); + }); +} diff --git a/test/e2e/04_nosql_workspaces/e2e_mongo_multimode_viewer_test.dart b/test/e2e/04_nosql_workspaces/e2e_mongo_multimode_viewer_test.dart new file mode 100644 index 00000000..8a96ce8b --- /dev/null +++ b/test/e2e/04_nosql_workspaces/e2e_mongo_multimode_viewer_test.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mongo_dart/mongo_dart.dart' show ObjectId; +import 'package:querya_desktop/features/mongodb/mongo_document_tree_view.dart'; +import 'package:querya_desktop/features/mongodb/mongo_documents_json_view.dart'; +import 'package:querya_desktop/features/mongodb/mongo_documents_table_view.dart'; +import 'package:querya_desktop/features/mongodb/mongo_documents_view.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +const _timeout = Timeout(Duration(seconds: 60)); + +final _docs = >[ + { + '_id': ObjectId.fromHexString('507f1f77bcf86cd799439011'), + 'name': 'Querya', + 'address': { + 'city': 'Berlin', + 'geo': [ + {'lat': 52.5}, + ], + }, + }, + { + '_id': ObjectId.fromHexString('507f1f77bcf86cd799439012'), + 'name': 'MongoDB', + }, +]; + +/// Switches between the same document set in the Table / Tree / JSON viewers, +/// the way [MongoDocumentsView] does after a view-mode change. +class _Viewer extends material.StatefulWidget { + const _Viewer({this.onDocumentTap}); + + final void Function(Map)? onDocumentTap; + + @override + material.State<_Viewer> createState() => _ViewerState(); +} + +class _ViewerState extends material.State<_Viewer> { + MongoDocumentsViewMode _mode = MongoDocumentsViewMode.table; + + @override + material.Widget build(material.BuildContext context) { + return material.Scaffold( + body: material.Column( + children: [ + material.Row( + children: [ + for (final mode in const [ + MongoDocumentsViewMode.table, + MongoDocumentsViewMode.tree, + MongoDocumentsViewMode.json, + ]) + material.TextButton( + onPressed: () => setState(() => _mode = mode), + child: material.Text('mode:${mode.id}'), + ), + ], + ), + material.Expanded( + child: switch (_mode) { + MongoDocumentsViewMode.tree => MongoDocumentTreeView( + documents: _docs, + onDocumentTap: widget.onDocumentTap, + ), + MongoDocumentsViewMode.json => + MongoDocumentsJsonView(documents: _docs), + _ => MongoDocumentsTableView( + documents: _docs, + onDocumentTap: widget.onDocumentTap, + ), + }, + ), + ], + ), + ); + } +} + +void main() { + Future open(WidgetTester tester, + {void Function(Map)? onDocumentTap}) async { + await tester.binding.setSurfaceSize(const material.Size(1200, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + queryaThemeTestShell(child: _Viewer(onDocumentTap: onDocumentTap)), + ); + await tester.pumpAndSettle(); + } + + testWidgets('table → tree → JSON keep showing the same documents', + timeout: _timeout, (tester) async { + await open(tester); + + expect(find.byType(MongoDocumentsTableView), findsOneWidget); + expect( + find.text( + '2 rows · Click a row to select · Click column header to sort'), + findsOneWidget, + ); + + await tester.tap(find.text('mode:tree')); + await tester.pumpAndSettle(); + expect(find.byType(MongoDocumentTreeView), findsOneWidget); + expect(find.text('"Querya"'), findsOneWidget); + expect(find.text('"MongoDB"'), findsOneWidget); + + await tester.tap(find.text('mode:json')); + await tester.pumpAndSettle(); + expect(find.byType(MongoDocumentsJsonView), findsOneWidget); + + await tester.tap(find.text('mode:table')); + await tester.pumpAndSettle(); + expect(find.byType(MongoDocumentsTableView), findsOneWidget); + }); + + testWidgets('tree view folds nested fields with Collapse All / Expand All', + timeout: _timeout, (tester) async { + await open(tester); + await tester.tap(find.text('mode:tree')); + await tester.pumpAndSettle(); + + expect(find.text('address'), findsOneWidget); + expect(find.text('"Querya"'), findsOneWidget); + + await tester.tap(find.text('Collapse All')); + await tester.pumpAndSettle(); + expect(find.text('"Querya"'), findsNothing); + expect(find.text('address'), findsNothing); + + await tester.tap(find.text('Expand All')); + await tester.pumpAndSettle(); + expect(find.text('"Querya"'), findsOneWidget); + expect(find.text('address'), findsOneWidget); + }); +} diff --git a/test/e2e/04_nosql_workspaces/e2e_redis_keys_tree_and_types_test.dart b/test/e2e/04_nosql_workspaces/e2e_redis_keys_tree_and_types_test.dart new file mode 100644 index 00000000..9972586e --- /dev/null +++ b/test/e2e/04_nosql_workspaces/e2e_redis_keys_tree_and_types_test.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/redis_bulk.dart'; +import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/features/redis/redis_key_editor.dart'; +import 'package:querya_desktop/features/redis/redis_keys_view.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +const _timeout = Timeout(Duration(seconds: 60)); + +/// Remembers EXPIRE / PERSIST and reports the resulting TTL back. +class _TtlRedis extends RedisConnectionTestFake { + _TtlRedis({super.firstScanKeys, super.dbSizeResult, super.getResult}); + + int ttlValue = -1; + final List> writes = []; + + @override + Future sendCommand(List args) async { + final op = args.first.toString().toUpperCase(); + if (op == 'TTL') return ttlValue; + if (op == 'EXPIRE') { + writes.add(args); + ttlValue = int.parse(args.last.toString()); + return 1; + } + if (op == 'PERSIST') { + writes.add(args); + ttlValue = -1; + return 1; + } + return super.sendCommand(args); + } +} + +/// Keys tree on the left, editor of the tapped key on the right — the same +/// wiring the Redis explorer uses. +class _Explorer extends material.StatefulWidget { + const _Explorer({required this.connection}); + + final RedisConnection connection; + + @override + material.State<_Explorer> createState() => _ExplorerState(); +} + +class _ExplorerState extends material.State<_Explorer> { + RedisBulkValue? _key; + String _type = 'string'; + + @override + material.Widget build(material.BuildContext context) { + return material.Scaffold( + body: material.Row( + children: [ + material.SizedBox( + width: 900, + child: RedisKeysView( + connection: widget.connection, + database: 0, + onKeyTap: (key, type) => setState(() { + _key = key; + _type = type; + }), + ), + ), + material.Expanded( + child: _key == null + ? const material.SizedBox.shrink() + : RedisKeyEditor( + key: material.ValueKey(_key!.label), + connection: widget.connection, + database: 0, + keyName: _key!.label, + keyArg: _key!.commandArg, + keyType: _type, + ), + ), + ], + ), + ); + } +} + +void main() { + Future open(WidgetTester tester, RedisConnection redis) async { + await redis.connect(); + addTearDown(redis.disconnect); + await tester.binding.setSurfaceSize(const material.Size(1700, 760)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + queryaThemeTestShell(child: _Explorer(connection: redis)), + ); + await tester.pumpAndSettle(); + } + + testWidgets('keys group into a folder tree and a leaf opens its editor', + timeout: _timeout, (tester) async { + await open( + tester, + _TtlRedis( + firstScanKeys: const ['user:profile', 'user:settings', 'standalone'], + dbSizeResult: 3, + getResult: 'hello', + ), + ); + + expect(find.text('user:profile'), findsOneWidget); + + await tester.tap(find.text('Tree')); + await tester.pumpAndSettle(); + expect(find.text('user'), findsOneWidget); + expect(find.text('2 keys'), findsOneWidget); + expect(find.text('profile'), findsOneWidget); + + await tester.tap(find.text('profile')); + await tester.pumpAndSettle(); + expect(find.text('hello'), findsOneWidget); + expect(find.text('Save'), findsOneWidget); + }); + + testWidgets('Set TTL applies EXPIRE and shows the new expiry', + timeout: _timeout, (tester) async { + final redis = _TtlRedis( + firstScanKeys: const ['session:1'], + dbSizeResult: 1, + getResult: 'token', + ); + await open(tester, redis); + + await tester.tap(find.text('session:1')); + await tester.pumpAndSettle(); + expect(find.text('No expiry'), findsOneWidget); + + await tester.tap(find.byTooltip('Set TTL')); + await tester.pumpAndSettle(); + expect(find.text('Enter TTL in seconds (0 to remove)'), findsOneWidget); + + await tester.enterText( + find.descendant( + of: find.byType(QueryaModalDialog), + matching: find.byType(TextField), + ), + '120', + ); + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + + expect(redis.writes, hasLength(1)); + expect(redis.writes.single.first, 'EXPIRE'); + expect(find.text('TTL set to 120 seconds'), findsOneWidget); + expect(find.text('2m 0s'), findsOneWidget); + // Let the success banner's auto-clear timer finish. + await tester.pump(const Duration(seconds: 10)); + }); + + testWidgets('TTL 0 removes the expiry with PERSIST', timeout: _timeout, + (tester) async { + final redis = _TtlRedis( + firstScanKeys: const ['session:1'], + dbSizeResult: 1, + getResult: 'token', + )..ttlValue = 300; + await open(tester, redis); + + await tester.tap(find.text('session:1')); + await tester.pumpAndSettle(); + expect(find.text('5m 0s'), findsOneWidget); + + await tester.tap(find.byTooltip('Set TTL')); + await tester.pumpAndSettle(); + await tester.enterText( + find.descendant( + of: find.byType(QueryaModalDialog), + matching: find.byType(TextField), + ), + '0', + ); + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + + expect(redis.writes.single.first, 'PERSIST'); + expect(find.text('TTL removed'), findsOneWidget); + expect(find.text('No expiry'), findsOneWidget); + await tester.pump(const Duration(seconds: 10)); + }); +}