diff --git a/App.tsx b/App.tsx index 83359c9..80e4ac3 100644 --- a/App.tsx +++ b/App.tsx @@ -6,7 +6,7 @@ import { ErrorBoundary } from './src/components/common/ErrorBoundary'; import AppNavigator from './src/navigation/AppNavigator'; import socketService from './src/services/socket'; import { useAppStore } from './src/store'; - +import logger from './src/utils/logger'; // Notification imports import { setupNotificationNavigation } from './src/navigation/linking'; import apiClient from './src/services/api/axios.config'; @@ -18,16 +18,10 @@ import { } from './src/services/pushNotifications'; import { handleNotificationReceived } from './src/utils/notificationHandlers'; -// Enable error logging to console (visible in Metro bundler) +// Centralized logging is handled by src/utils/logger. +// Suppress known non-actionable navigation warnings in all environments. if (__DEV__) { - // Log all errors to console - const originalError = console.error; - console.error = (...args) => { - originalError(...args); - // Errors will appear in Metro bundler terminal - }; - - // Show warnings in console but don't break the app + logger.debug('Development mode: centralized logger active'); LogBox.ignoreLogs([ 'Non-serializable values were found in the navigation state', ]); @@ -52,7 +46,7 @@ export default function App() { // Check if app was launched from a notification getLastNotificationResponse().then((response) => { if (response) { - console.log('App launched from notification:', response); + logger.info('App launched from notification:', response); } }); diff --git a/src/__tests__/utils/logger.test.ts b/src/__tests__/utils/logger.test.ts new file mode 100644 index 0000000..527a116 --- /dev/null +++ b/src/__tests__/utils/logger.test.ts @@ -0,0 +1,150 @@ +import { LogLevel } from '../../utils/logger'; + +describe('logger utility', () => { + describe('LogLevel enum', () => { + it('exports the correct string values for each level', () => { + expect(LogLevel.DEBUG).toBe('DEBUG'); + expect(LogLevel.INFO).toBe('INFO'); + expect(LogLevel.WARN).toBe('WARN'); + expect(LogLevel.ERROR).toBe('ERROR'); + }); + }); + + describe('in development mode (__DEV__ = true)', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + let devLogger: typeof import('../../utils/logger').logger; + + beforeAll(() => { + (global as any).__DEV__ = true; + jest.resetModules(); + devLogger = require('../../utils/logger').logger; + }); + + afterAll(() => { + delete (global as any).__DEV__; + jest.resetModules(); + }); + + it('info() forwards to console.log with the INFO prefix', () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + devLogger.info('hello world'); + expect(spy).toHaveBeenCalledWith('ℹ️ [INFO]', 'hello world'); + spy.mockRestore(); + }); + + it('info() forwards multiple arguments', () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + devLogger.info('user:', { id: 1 }); + expect(spy).toHaveBeenCalledWith('ℹ️ [INFO]', 'user:', { id: 1 }); + spy.mockRestore(); + }); + + it('warn() forwards to console.warn with the WARN prefix', () => { + const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + devLogger.warn('low disk space'); + expect(spy).toHaveBeenCalledWith('⚠️ [WARN]', 'low disk space'); + spy.mockRestore(); + }); + + it('debug() forwards to console.log with the DEBUG prefix', () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + devLogger.debug('raw payload', { foo: 'bar' }); + expect(spy).toHaveBeenCalledWith('🐛 [DEBUG]', 'raw payload', { foo: 'bar' }); + spy.mockRestore(); + }); + + it('component() includes the component name and action', () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + devLogger.component('ProfileScreen', 'mounted'); + expect(spy).toHaveBeenCalledWith('📱 [ProfileScreen] mounted', ''); + spy.mockRestore(); + }); + + it('component() includes optional data when provided', () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + devLogger.component('CourseList', 'updated', { count: 5 }); + expect(spy).toHaveBeenCalledWith('📱 [CourseList] updated', { count: 5 }); + spy.mockRestore(); + }); + }); + + describe('error() — always logs regardless of environment', () => { + // Re-import logger once for these tests without resetting module state. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { logger } = require('../../utils/logger'); + + it('calls console.error with the ERROR prefix', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + logger.error('something went wrong'); + expect(spy).toHaveBeenCalledWith('❌ [ERROR]', 'something went wrong'); + spy.mockRestore(); + }); + + it('prints the stack trace when an Error instance is the first argument', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const err = new Error('boom'); + logger.error(err); + expect(spy).toHaveBeenCalledWith('❌ [ERROR]', err); + expect(spy).toHaveBeenCalledWith('Stack:', err.stack); + spy.mockRestore(); + }); + + it('does not print a stack trace for non-Error arguments', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + logger.error('plain string error'); + expect(spy).toHaveBeenCalledTimes(1); + spy.mockRestore(); + }); + }); + + describe('in production mode (__DEV__ = false)', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + let prodLogger: typeof import('../../utils/logger').logger; + + beforeAll(() => { + (global as any).__DEV__ = false; + jest.resetModules(); + prodLogger = require('../../utils/logger').logger; + }); + + afterAll(() => { + delete (global as any).__DEV__; + jest.resetModules(); + }); + + it('info() is silent in production', () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + prodLogger.info('should be silent'); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('warn() is silent in production', () => { + const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + prodLogger.warn('should be silent'); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('debug() is silent in production', () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + prodLogger.debug('should be silent'); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('component() is silent in production', () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + prodLogger.component('HomeScreen', 'mounted'); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('error() still logs in production', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + prodLogger.error('prod error'); + expect(spy).toHaveBeenCalledWith('❌ [ERROR]', 'prod error'); + spy.mockRestore(); + }); + }); +}); diff --git a/src/services/api/axios.config.ts b/src/services/api/axios.config.ts index 5db4ae5..eac7506 100644 --- a/src/services/api/axios.config.ts +++ b/src/services/api/axios.config.ts @@ -1,4 +1,5 @@ import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; +import logger from "../../utils/logger"; import { getAccessToken, getRefreshToken, saveTokens } from "../secureStorage"; import requestQueue from "./requestQueue"; @@ -50,13 +51,11 @@ apiClient.interceptors.response.use( _retry?: boolean; }; - // ── Log non-network errors in dev ──────────────────────────────────── - if (__DEV__) { - if (error.code === "ERR_NETWORK" || error.message === "Network Error") { - console.warn("⚠️ API not available (running in offline mode)"); - } else if (error.response?.status !== 401) { - console.error("API Error:", error.response?.data || error.message); - } + // ── Log non-network errors ──────────────────────────────────────────── + if (error.code === "ERR_NETWORK" || error.message === "Network Error") { + logger.warn("API not available (running in offline mode)"); + } else if (error.response?.status !== 401) { + logger.error("API Error:", error.response?.data || error.message); } // ── Queue network errors for retry ─────────────────────────────────── diff --git a/src/services/socket/index.ts b/src/services/socket/index.ts index cf156b8..53afa4a 100644 --- a/src/services/socket/index.ts +++ b/src/services/socket/index.ts @@ -1,4 +1,5 @@ import { io, Socket } from "socket.io-client"; +import logger from "../../utils/logger"; class SocketService { private socket: Socket | null = null; @@ -15,15 +16,15 @@ class SocketService { }); this.socket.on("connect", () => { - console.log("Socket connected:", this.socket?.id); + logger.info("Socket connected:", this.socket?.id); }); this.socket.on("disconnect", () => { - console.log("Socket disconnected"); + logger.info("Socket disconnected"); }); this.socket.on("error", (error) => { - console.error("Socket error:", error); + logger.error("Socket error:", error); }); } return this.socket; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 282204c..4d1fd40 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,13 +1,27 @@ /** - * Enhanced logging utility that ensures errors are visible in Metro bundler - * All logs appear in your PC terminal where npm start is running + * Centralized logging utility for TeachLink Mobile. + * + * - Development: all log levels are output to the Metro bundler terminal. + * - Production: only errors are logged; info/warn/debug are silenced. + * + * Import and use this instead of direct console.* calls throughout the codebase. */ -const isDev = __DEV__; +/** Supported log severity levels. */ +export enum LogLevel { + DEBUG = 'DEBUG', + INFO = 'INFO', + WARN = 'WARN', + ERROR = 'ERROR', +} + +// Safe __DEV__ check — falls back gracefully in non-RN environments (e.g. Jest). +const isDev = + typeof __DEV__ !== 'undefined' ? __DEV__ : process.env.NODE_ENV !== 'production'; export const logger = { /** - * Log info messages (appears in Metro terminal) + * Log informational messages. Only emitted in development. */ info: (...args: any[]) => { if (isDev) { @@ -16,7 +30,7 @@ export const logger = { }, /** - * Log warnings (appears in Metro terminal in YELLOW) + * Log warning messages. Only emitted in development. */ warn: (...args: any[]) => { if (isDev) { @@ -25,19 +39,19 @@ export const logger = { }, /** - * Log errors (appears in Metro terminal in RED) + * Log error messages. Always emitted, including in production. + * Prints the stack trace when the first argument is an Error instance. */ error: (...args: any[]) => { console.error('❌ [ERROR]', ...args); - - // Also log stack trace if available + if (args[0] instanceof Error) { console.error('Stack:', args[0].stack); } }, /** - * Log debug messages + * Log debug-level messages. Only emitted in development. */ debug: (...args: any[]) => { if (isDev) { @@ -46,11 +60,11 @@ export const logger = { }, /** - * Log component lifecycle + * Log a component lifecycle event. Only emitted in development. */ component: (componentName: string, action: string, data?: any) => { if (isDev) { - console.log(`📱 [${componentName}] ${action}`, data || ''); + console.log(`📱 [${componentName}] ${action}`, data ?? ''); } }, };