diff --git a/apps/lib/core/logging/logger.dart b/apps/lib/core/logging/logger.dart index 39e3a8b..d85fd1b 100644 --- a/apps/lib/core/logging/logger.dart +++ b/apps/lib/core/logging/logger.dart @@ -1,71 +1,89 @@ +import 'package:flutter/foundation.dart'; +import 'log_config.dart'; +import 'log_entry.dart'; import 'log_service.dart'; LogService? _globalLogService; class Logger { final String module; - final LogService _service; + final LogService? _service; + final bool _isNoOp; - Logger(this.module, this._service); + Logger(this.module, this._service) : _isNoOp = _service == null; factory Logger.get(String module) { - return Logger(module, _ensureService()); + return Logger(module, _globalLogService); } static void setLogService(LogService service) { _globalLogService = service; } - static LogService _ensureService() { - return _globalLogService ?? - (throw StateError('LogService not initialized')); + static LogService? _ensureService() { + return _globalLogService; } void debug({ required String message, Map? extra, StackTrace? stackTrace, - }) => _service.debug( - message: message, - module: module, - extra: extra ?? {}, - stackTrace: stackTrace, - ); + }) { + if (_isNoOp) return; + _service!.debug( + message: message, + module: module, + extra: extra ?? {}, + stackTrace: stackTrace, + ); + } void info({ required String message, Map? extra, StackTrace? stackTrace, - }) => _service.info( - message: message, - module: module, - extra: extra ?? {}, - stackTrace: stackTrace, - ); + }) { + if (_isNoOp) return; + _service!.info( + message: message, + module: module, + extra: extra ?? {}, + stackTrace: stackTrace, + ); + } void warning({ required String message, Map? extra, StackTrace? stackTrace, - }) => _service.warning( - message: message, - module: module, - extra: extra ?? {}, - stackTrace: stackTrace, - ); + }) { + if (_isNoOp) return; + _service!.warning( + message: message, + module: module, + extra: extra ?? {}, + stackTrace: stackTrace, + ); + } void error({ required String message, required Object error, required StackTrace stackTrace, Map? extra, - }) => _service.error( - message: message, - error: error, - stackTrace: stackTrace, - module: module, - extra: extra, - ); + }) { + if (_isNoOp) { + debugPrint('[$module] ERROR: $message, error: $error'); + return; + } + _service!.error( + message: message, + error: error, + stackTrace: stackTrace, + module: module, + extra: extra, + ); + } } Logger getLogger(String module) => Logger.get(module); diff --git a/apps/lib/features/calendar/data/repositories/calendar_repository.dart b/apps/lib/features/calendar/data/repositories/calendar_repository.dart index 7055b0a..86341f3 100644 --- a/apps/lib/features/calendar/data/repositories/calendar_repository.dart +++ b/apps/lib/features/calendar/data/repositories/calendar_repository.dart @@ -145,9 +145,9 @@ class CalendarRepository extends CachedRepository> { required DateTime startAt, required DateTime endAt, }) async { + final start = Uri.encodeQueryComponent(startAt.toUtc().toIso8601String()); + final end = Uri.encodeQueryComponent(endAt.toUtc().toIso8601String()); try { - final start = Uri.encodeQueryComponent(startAt.toUtc().toIso8601String()); - final end = Uri.encodeQueryComponent(endAt.toUtc().toIso8601String()); final response = await _apiClient.get>( '$_prefix?start_at=$start&end_at=$end', ); diff --git a/apps/test/app/services/app_prewarm_orchestrator_test.dart b/apps/test/app/services/app_prewarm_orchestrator_test.dart index 0047d75..75afbdd 100644 --- a/apps/test/app/services/app_prewarm_orchestrator_test.dart +++ b/apps/test/app/services/app_prewarm_orchestrator_test.dart @@ -13,12 +13,16 @@ import 'package:social_app/features/messages/data/repositories/inbox_repository. class _FakeApiClient implements IApiClient { @override - Future> delete(String path, {data, Options? options}) { + Future> delete(String path, {dynamic data, Options? options}) { throw UnimplementedError(); } @override - Future> get(String path, {Options? options}) { + Future> get( + String path, { + Map? queryParameters, + Options? options, + }) { throw UnimplementedError(); } @@ -31,17 +35,17 @@ class _FakeApiClient implements IApiClient { } @override - Future> patch(String path, {data, Options? options}) { + Future> patch(String path, {dynamic data, Options? options}) { throw UnimplementedError(); } @override - Future> post(String path, {data, Options? options}) { + Future> post(String path, {dynamic data, Options? options}) { throw UnimplementedError(); } @override - Future> put(String path, {data, Options? options}) { + Future> put(String path, {dynamic data, Options? options}) { throw UnimplementedError(); } } @@ -127,6 +131,7 @@ void main() { prewarmChatHistory: () async {}, prewarmCalendarToday: () async {}, prewarmUnreadInbox: () async {}, + prewarmCalendarReminderWindow: () async {}, ); await orchestrator.ensureStartedFor('u1'); @@ -145,6 +150,7 @@ void main() { prewarmChatHistory: () => completer.future, prewarmCalendarToday: () => completer.future, prewarmUnreadInbox: () => completer.future, + prewarmCalendarReminderWindow: () => completer.future, ); await orchestrator.ensureStartedFor('u1'); diff --git a/apps/test/core/logging/log_entry_test.dart b/apps/test/core/logging/log_entry_test.dart index 8626c87..6ef0b5c 100644 --- a/apps/test/core/logging/log_entry_test.dart +++ b/apps/test/core/logging/log_entry_test.dart @@ -3,7 +3,7 @@ import 'package:social_app/core/logging/log_entry.dart'; void main() { group('LogEntry', () { - test('toJson should serialize correctly', () { + test('toJson should serialize correctly with all fields', () { final entry = LogEntry( timestamp: DateTime(2026, 4, 1, 10, 0, 0), level: LogLevel.error, @@ -18,52 +18,17 @@ void main() { final json = entry.toJson(); + expect(json['timestamp'], '2026-04-01T10:00:00.000'); expect(json['level'], 'error'); + expect(json['message'], 'Test error'); + expect(json['module'], 'test.module'); + expect(json['func_name'], 'testFunc'); + expect(json['line_no'], 42); expect(json['error_type'], 'TestError'); + expect(json['stack_trace'], 'test stack'); expect(json['extra'], {'key': 'value'}); }); - test('toConsoleString should format correctly', () { - final entry = LogEntry( - timestamp: DateTime(2026, 4, 1, 10, 0, 0), - level: LogLevel.debug, - message: 'Test message', - module: 'test.module', - funcName: 'testFunc', - lineNo: 42, - ); - - final result = entry.toConsoleString(); - - expect(result, contains('DEBUG')); - expect(result, contains('test.module')); - expect(result, contains('testFunc')); - expect(result, contains('@42')); - expect(result, contains('Test message')); - }); - - test('toFileString should format correctly', () { - final entry = LogEntry( - timestamp: DateTime(2026, 4, 1, 10, 0, 0), - level: LogLevel.error, - message: 'Test error', - module: 'test.module', - funcName: 'testFunc', - lineNo: 42, - errorType: 'TestError', - stackTrace: 'test stack trace', - ); - - final result = entry.toFileString(); - - expect(result, contains('[test.module]')); - expect(result, contains('at testFunc:42')); - expect(result, contains('Test error')); - expect(result, contains('Error: TestError')); - expect(result, contains('StackTrace:')); - expect(result, contains('test stack trace')); - }); - test('toJson should omit null fields', () { final entry = LogEntry( timestamp: DateTime(2026, 4, 1, 10, 0, 0), @@ -80,5 +45,139 @@ void main() { expect(json.containsKey('stack_trace'), false); expect(json.containsKey('extra'), false); }); + + test('toJson should omit extra when empty', () { + final entry = LogEntry( + timestamp: DateTime(2026, 4, 1, 10, 0, 0), + level: LogLevel.info, + message: 'Test', + module: 'test', + extra: {}, + ); + + final json = entry.toJson(); + + expect(json.containsKey('extra'), false); + }); + + test('toConsoleString should format debug level correctly', () { + final entry = LogEntry( + timestamp: DateTime(2026, 4, 1, 10, 0, 0), + level: LogLevel.debug, + message: 'Debug message', + module: 'test.module', + funcName: 'testFunc', + lineNo: 42, + ); + + final result = entry.toConsoleString(); + + expect(result, contains('DEBUG')); + expect(result, contains('test.module')); + expect(result, contains('testFunc')); + expect(result, contains('@42')); + expect(result, contains('Debug message')); + }); + + test('toConsoleString should format error level with error type', () { + final entry = LogEntry( + timestamp: DateTime(2026, 4, 1, 10, 0, 0), + level: LogLevel.error, + message: 'Error occurred', + module: 'test.module', + funcName: 'handleError', + lineNo: 100, + errorType: 'SocketException', + ); + + final result = entry.toConsoleString(); + + expect(result, contains('ERROR')); + expect(result, contains('[SocketException]')); + expect(result, contains('handleError')); + expect(result, contains('@100')); + }); + + test('toConsoleString should format without location when null', () { + final entry = LogEntry( + timestamp: DateTime(2026, 4, 1, 10, 0, 0), + level: LogLevel.info, + message: 'Simple message', + module: 'simple.module', + ); + + final result = entry.toConsoleString(); + + expect(result, contains('INFO')); + expect(result, contains('simple.module')); + expect(result, contains('Simple message')); + }); + + test('toFileString should format correctly with all fields', () { + final entry = LogEntry( + timestamp: DateTime(2026, 4, 1, 10, 0, 0), + level: LogLevel.error, + message: 'Test error', + module: 'test.module', + funcName: 'testFunc', + lineNo: 42, + errorType: 'TestError', + stackTrace: 'test stack trace', + extra: {'key': 'value'}, + ); + + final result = entry.toFileString(); + + expect(result, contains('[test.module]')); + expect(result, contains('at testFunc:42')); + expect(result, contains('Test error')); + expect(result, contains('Error: TestError')); + expect(result, contains('StackTrace:')); + expect(result, contains('test stack trace')); + expect(result, contains('Extra:')); + }); + + test('toFileString should format with only funcName (no lineNo)', () { + final entry = LogEntry( + timestamp: DateTime(2026, 4, 1, 10, 0, 0), + level: LogLevel.warning, + message: 'Warning message', + module: 'warn.module', + funcName: 'warningFunc', + ); + + final result = entry.toFileString(); + + expect(result, contains('WARNING [warn.module]')); + expect(result, contains('at warningFunc')); + expect(result, isNot(contains(':42'))); + }); + + test('toFileString should format with only lineNo (no funcName)', () { + final entry = LogEntry( + timestamp: DateTime(2026, 4, 1, 10, 0, 0), + level: LogLevel.warning, + message: 'Warning at line', + module: 'warn.module', + lineNo: 99, + ); + + final result = entry.toFileString(); + + expect(result, contains('at :99')); + }); + + test('LogLevel ordering is correct', () { + expect(LogLevel.debug.index, lessThan(LogLevel.info.index)); + expect(LogLevel.info.index, lessThan(LogLevel.warning.index)); + expect(LogLevel.warning.index, lessThan(LogLevel.error.index)); + }); + + test('LogLevel names are lowercase', () { + expect(LogLevel.debug.name, 'debug'); + expect(LogLevel.info.name, 'info'); + expect(LogLevel.warning.name, 'warning'); + expect(LogLevel.error.name, 'error'); + }); }); } diff --git a/apps/test/core/logging/log_service_test.dart b/apps/test/core/logging/log_service_test.dart new file mode 100644 index 0000000..ab0a338 --- /dev/null +++ b/apps/test/core/logging/log_service_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/logging/log_config.dart'; +import 'package:social_app/core/logging/log_entry.dart'; +import 'package:social_app/core/logging/log_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('LogService', () { + test('LogConfig.forDebug creates correct config', () { + final config = LogConfig.forDebug(); + + expect(config.minLevel, LogLevel.debug); + expect(config.output, LogOutput.console); + }); + + test('LogConfig.forRelease creates correct config', () { + final config = LogConfig.forRelease(); + + expect(config.minLevel, LogLevel.warning); + expect(config.output, LogOutput.file); + expect(config.logFileName, 'app.log'); + expect(config.logDir, 'logs'); + }); + + test('LogConfig default values are correct', () { + const config = LogConfig(); + + expect(config.minLevel, LogLevel.debug); + expect(config.output, LogOutput.console); + expect(config.logFileName, 'app.log'); + expect(config.logDir, 'logs'); + }); + + test('LogService with console output logs all levels', () async { + final service = await LogService.create( + config: const LogConfig(minLevel: LogLevel.debug), + ); + + service.debug(message: 'debug msg', module: 'test'); + service.info(message: 'info msg', module: 'test', extra: {'a': 1}); + service.warning(message: 'warn msg', module: 'test', extra: {'a': 1}); + service.error( + message: 'error msg', + error: Exception('test'), + stackTrace: StackTrace.current, + module: 'test', + ); + + service.flush(); + }); + + test('LogService filters by minLevel - info', () async { + final service = await LogService.create( + config: const LogConfig(minLevel: LogLevel.info), + ); + + service.debug(message: 'debug msg', module: 'test'); + + service.flush(); + }); + + test('error method extracts error type from exception', () async { + final service = await LogService.create( + config: const LogConfig(minLevel: LogLevel.error), + ); + + try { + throw ArgumentError('test error'); + } catch (e, st) { + service.error( + message: 'Test error occurred', + error: e, + stackTrace: st, + module: 'test.module', + extra: {'test': true}, + ); + } + + service.flush(); + }); + + test('debug method captures StackTrace.current', () async { + final service = await LogService.create( + config: const LogConfig(minLevel: LogLevel.debug), + ); + + service.debug(message: 'Debug with location', module: 'test.location'); + + service.flush(); + }); + }); +} diff --git a/apps/test/data/repositories/shared_repositories_test.dart b/apps/test/data/repositories/shared_repositories_test.dart index 60a7449..9543fb9 100644 --- a/apps/test/data/repositories/shared_repositories_test.dart +++ b/apps/test/data/repositories/shared_repositories_test.dart @@ -22,7 +22,11 @@ class _FakeApiClient implements IApiClient { void setPatch(String path, dynamic data) => _patchResponses[path] = data; @override - Future> get(String path, {Options? options}) async { + Future> get( + String path, { + Map? queryParameters, + Options? options, + }) async { if (!_getResponses.containsKey(path)) { throw StateError('missing GET mock for $path'); } @@ -33,7 +37,11 @@ class _FakeApiClient implements IApiClient { } @override - Future> post(String path, {data, Options? options}) async { + Future> post( + String path, { + dynamic data, + Options? options, + }) async { if (!_postResponses.containsKey(path)) { throw StateError('missing POST mock for $path'); } @@ -44,7 +52,11 @@ class _FakeApiClient implements IApiClient { } @override - Future> patch(String path, {data, Options? options}) async { + Future> patch( + String path, { + dynamic data, + Options? options, + }) async { if (!_patchResponses.containsKey(path)) { throw StateError('missing PATCH mock for $path'); } @@ -55,7 +67,7 @@ class _FakeApiClient implements IApiClient { } @override - Future> delete(String path, {data, Options? options}) { + Future> delete(String path, {dynamic data, Options? options}) { throw UnimplementedError(); } @@ -68,7 +80,7 @@ class _FakeApiClient implements IApiClient { } @override - Future> put(String path, {data, Options? options}) { + Future> put(String path, {dynamic data, Options? options}) { throw UnimplementedError(); } } @@ -111,7 +123,7 @@ void main() { 'id': 'f1', 'sender': {'id': 'u1', 'username': 'alice', 'avatar_url': null}, 'recipient': {'id': 'u2', 'username': 'bob', 'avatar_url': null}, - 'content': 'hi', + 'content': {'message': 'hi'}, 'status': 'accepted', 'created_at': '2026-03-27T08:00:00Z', }); @@ -137,7 +149,7 @@ void main() { 'id': 'f1', 'sender': {'id': 'u1', 'username': 'alice', 'avatar_url': null}, 'recipient': {'id': 'u2', 'username': 'bob', 'avatar_url': null}, - 'content': 'hi', + 'content': {'message': 'hi'}, 'status': 'pending', 'created_at': '2026-03-27T08:00:00Z', });