diff --git a/VEZA_COMPLETE_MVP_TODOLIST.json b/VEZA_COMPLETE_MVP_TODOLIST.json index 4870e2f6e..657856df9 100644 --- a/VEZA_COMPLETE_MVP_TODOLIST.json +++ b/VEZA_COMPLETE_MVP_TODOLIST.json @@ -9224,7 +9224,7 @@ "description": "Add type guard functions for safe type narrowing", "owner": "frontend", "estimated_hours": 4, - "status": "todo", + "status": "completed", "files_involved": [], "implementation_steps": [ { @@ -9245,7 +9245,8 @@ "Unit tests", "Integration tests" ], - "notes": "" + "notes": "Created comprehensive type guard functions for runtime type checking. Implemented guards for User, Track, Playlist, Conversation, Message, Session, AuditLog, Notification, ApiError, ApiResponse, PaginationData, and arrays. Added utility guards for UUID, Email, ISO8601Date, URL, and primitive types. All tests passing (44/44).", + "completed_at": "2025-12-25T14:38:55.453208Z" }, { "id": "FE-TYPE-005", diff --git a/apps/web/src/utils/typeGuards.test.ts b/apps/web/src/utils/typeGuards.test.ts new file mode 100644 index 000000000..7bfe988d6 --- /dev/null +++ b/apps/web/src/utils/typeGuards.test.ts @@ -0,0 +1,409 @@ +/** + * Tests for Type Guards + * FE-TYPE-004: Test type guard functions for runtime type checking + */ + +import { describe, it, expect } from 'vitest'; +import { + isUser, + isTrack, + isPlaylist, + isConversation, + isMessage, + isSession, + isAuditLog, + isNotification, + isApiError, + isApiResponse, + isPaginationData, + isUserArray, + isTrackArray, + isPlaylistArray, + isConversationArray, + isMessageArray, + isNotificationArray, + isUUID, + isEmail, + isISO8601Date, + isNonEmptyString, + isPositiveNumber, + isNonNegativeNumber, + isURL, + isPlainObject, + isArrayOf, + isNotNull, + isDefined, + isNumber, + isBoolean, + isString, +} from './typeGuards'; + +describe('typeGuards', () => { + describe('isUser', () => { + it('should return true for valid user', () => { + const user = { + id: '123e4567-e89b-12d3-a456-426614174000', + username: 'testuser', + email: 'test@example.com', + role: 'user', + is_active: true, + is_verified: true, + is_admin: false, + is_public: true, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + + expect(isUser(user)).toBe(true); + }); + + it('should return false for invalid user', () => { + expect(isUser(null)).toBe(false); + expect(isUser(undefined)).toBe(false); + expect(isUser({})).toBe(false); + expect(isUser({ id: '123' })).toBe(false); + }); + }); + + describe('isTrack', () => { + it('should return true for valid track', () => { + const track = { + id: '123e4567-e89b-12d3-a456-426614174000', + creator_id: '123e4567-e89b-12d3-a456-426614174001', + title: 'Test Track', + artist: 'Test Artist', + album: 'Test Album', + duration: 180, + genre: 'Rock', + year: 2024, + file_path: '/path/to/file.mp3', + file_size: 5000000, + format: 'mp3', + bitrate: 320, + sample_rate: 44100, + is_public: true, + status: 'completed', + stream_status: 'ready', + play_count: 0, + like_count: 0, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + + expect(isTrack(track)).toBe(true); + }); + + it('should return false for invalid track', () => { + expect(isTrack(null)).toBe(false); + expect(isTrack({})).toBe(false); + expect(isTrack({ id: '123' })).toBe(false); + }); + }); + + describe('isPlaylist', () => { + it('should return true for valid playlist', () => { + const playlist = { + id: '123e4567-e89b-12d3-a456-426614174000', + user_id: '123e4567-e89b-12d3-a456-426614174001', + title: 'Test Playlist', + is_public: true, + track_count: 10, + follower_count: 5, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + + expect(isPlaylist(playlist)).toBe(true); + }); + }); + + describe('isConversation', () => { + it('should return true for valid conversation', () => { + const conversation = { + id: '123e4567-e89b-12d3-a456-426614174000', + name: 'Test Conversation', + type: 'group', + creator_id: '123e4567-e89b-12d3-a456-426614174001', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + + expect(isConversation(conversation)).toBe(true); + }); + }); + + describe('isMessage', () => { + it('should return true for valid message', () => { + const message = { + id: '123e4567-e89b-12d3-a456-426614174000', + conversation_id: '123e4567-e89b-12d3-a456-426614174001', + sender_id: '123e4567-e89b-12d3-a456-426614174002', + content: 'Hello, world!', + message_type: 'text', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + + expect(isMessage(message)).toBe(true); + }); + }); + + describe('isNotification', () => { + it('should return true for valid notification', () => { + const notification = { + id: '123e4567-e89b-12d3-a456-426614174000', + user_id: '123e4567-e89b-12d3-a456-426614174001', + type: 'new_message', + content: 'You have a new message', + read: false, + created_at: '2024-01-01T00:00:00Z', + }; + + expect(isNotification(notification)).toBe(true); + }); + }); + + describe('isApiError', () => { + it('should return true for valid API error', () => { + const error = { + code: 400, + message: 'Bad Request', + timestamp: '2024-01-01T00:00:00Z', + }; + + expect(isApiError(error)).toBe(true); + }); + }); + + describe('isApiResponse', () => { + it('should return true for valid API response', () => { + const response = { + success: true, + data: { id: '123' }, + }; + + expect(isApiResponse(response)).toBe(true); + }); + + it('should return true for failed API response', () => { + const response = { + success: false, + error: { code: 400, message: 'Error' }, + }; + + expect(isApiResponse(response)).toBe(true); + }); + }); + + describe('isPaginationData', () => { + it('should return true for valid pagination data', () => { + const pagination = { + page: 1, + limit: 20, + total: 100, + total_pages: 5, + has_next: true, + has_prev: false, + }; + + expect(isPaginationData(pagination)).toBe(true); + }); + }); + + describe('isUUID', () => { + it('should return true for valid UUID', () => { + expect(isUUID('123e4567-e89b-12d3-a456-426614174000')).toBe(false); // Invalid UUID v4 + expect(isUUID('550e8400-e29b-41d4-a716-446655440000')).toBe(true); // Valid UUID v4 + }); + + it('should return false for invalid UUID', () => { + expect(isUUID('not-a-uuid')).toBe(false); + expect(isUUID('123')).toBe(false); + expect(isUUID(null)).toBe(false); + }); + }); + + describe('isEmail', () => { + it('should return true for valid email', () => { + expect(isEmail('test@example.com')).toBe(true); + }); + + it('should return false for invalid email', () => { + expect(isEmail('not-an-email')).toBe(false); + expect(isEmail('@example.com')).toBe(false); + expect(isEmail('test@')).toBe(false); + }); + }); + + describe('isISO8601Date', () => { + it('should return true for valid ISO8601 date', () => { + expect(isISO8601Date('2024-01-01T00:00:00Z')).toBe(true); + expect(isISO8601Date('2024-01-01T00:00:00.000Z')).toBe(true); + }); + + it('should return false for invalid date', () => { + expect(isISO8601Date('2024-01-01')).toBe(false); + expect(isISO8601Date('not-a-date')).toBe(false); + }); + }); + + describe('isNonEmptyString', () => { + it('should return true for non-empty string', () => { + expect(isNonEmptyString('hello')).toBe(true); + }); + + it('should return false for empty string', () => { + expect(isNonEmptyString('')).toBe(false); + }); + + it('should return false for non-string', () => { + expect(isNonEmptyString(null)).toBe(false); + expect(isNonEmptyString(123)).toBe(false); + }); + }); + + describe('isPositiveNumber', () => { + it('should return true for positive number', () => { + expect(isPositiveNumber(1)).toBe(true); + expect(isPositiveNumber(100)).toBe(true); + }); + + it('should return false for zero or negative', () => { + expect(isPositiveNumber(0)).toBe(false); + expect(isPositiveNumber(-1)).toBe(false); + }); + }); + + describe('isNonNegativeNumber', () => { + it('should return true for non-negative number', () => { + expect(isNonNegativeNumber(0)).toBe(true); + expect(isNonNegativeNumber(1)).toBe(true); + }); + + it('should return false for negative number', () => { + expect(isNonNegativeNumber(-1)).toBe(false); + }); + }); + + describe('isURL', () => { + it('should return true for valid URL', () => { + expect(isURL('https://example.com')).toBe(true); + expect(isURL('http://example.com/path')).toBe(true); + }); + + it('should return false for invalid URL', () => { + expect(isURL('not-a-url')).toBe(false); + expect(isURL('example.com')).toBe(false); + }); + }); + + describe('isPlainObject', () => { + it('should return true for plain object', () => { + expect(isPlainObject({})).toBe(true); + expect(isPlainObject({ key: 'value' })).toBe(true); + }); + + it('should return false for array', () => { + expect(isPlainObject([])).toBe(false); + }); + + it('should return false for null', () => { + expect(isPlainObject(null)).toBe(false); + }); + }); + + describe('isArrayOf', () => { + it('should return true for array of strings', () => { + expect(isArrayOf(['a', 'b', 'c'], isString)).toBe(true); + }); + + it('should return false for mixed array', () => { + expect(isArrayOf(['a', 1, 'c'], isString)).toBe(false); + }); + }); + + describe('isNotNull', () => { + it('should return true for non-null value', () => { + expect(isNotNull('value')).toBe(true); + expect(isNotNull(0)).toBe(true); + }); + + it('should return false for null or undefined', () => { + expect(isNotNull(null)).toBe(false); + expect(isNotNull(undefined)).toBe(false); + }); + }); + + describe('isDefined', () => { + it('should return true for defined value', () => { + expect(isDefined('value')).toBe(true); + expect(isDefined(null)).toBe(true); + }); + + it('should return false for undefined', () => { + expect(isDefined(undefined)).toBe(false); + }); + }); + + describe('isNumber', () => { + it('should return true for number', () => { + expect(isNumber(0)).toBe(true); + expect(isNumber(123)).toBe(true); + }); + + it('should return false for NaN', () => { + expect(isNumber(NaN)).toBe(false); + }); + }); + + describe('isBoolean', () => { + it('should return true for boolean', () => { + expect(isBoolean(true)).toBe(true); + expect(isBoolean(false)).toBe(true); + }); + + it('should return false for non-boolean', () => { + expect(isBoolean(0)).toBe(false); + expect(isBoolean('true')).toBe(false); + }); + }); + + describe('isString', () => { + it('should return true for string', () => { + expect(isString('hello')).toBe(true); + expect(isString('')).toBe(true); + }); + + it('should return false for non-string', () => { + expect(isString(123)).toBe(false); + expect(isString(null)).toBe(false); + }); + }); + + describe('Array type guards', () => { + it('should validate user array', () => { + const users = [ + { + id: '123e4567-e89b-12d3-a456-426614174000', + username: 'user1', + email: 'user1@example.com', + role: 'user', + is_active: true, + is_verified: true, + is_admin: false, + is_public: true, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + ]; + + expect(isUserArray(users)).toBe(true); + }); + + it('should reject invalid array', () => { + expect(isUserArray([{ id: '123' }])).toBe(false); + expect(isUserArray([])).toBe(true); // Empty array is valid + }); + }); +}); + diff --git a/apps/web/src/utils/typeGuards.ts b/apps/web/src/utils/typeGuards.ts new file mode 100644 index 000000000..f88bc68c1 --- /dev/null +++ b/apps/web/src/utils/typeGuards.ts @@ -0,0 +1,399 @@ +/** + * Type Guards + * FE-TYPE-004: Add type guard functions for safe type narrowing + * + * Provides runtime type checking functions that allow TypeScript to narrow + * types safely, improving type safety throughout the application. + */ + +import type { + User, + Track, + Playlist, + Conversation, + Message, + Session, + AuditLog, + Notification, + ApiResponse, + ApiError, + PaginationData, +} from '@/types/api'; + +/** + * Type guard for User + */ +export function isUser(value: unknown): value is User { + return ( + typeof value === 'object' && + value !== null && + 'id' in value && + 'username' in value && + 'email' in value && + 'role' in value && + typeof (value as any).id === 'string' && + typeof (value as any).username === 'string' && + typeof (value as any).email === 'string' && + typeof (value as any).role === 'string' + ); +} + +/** + * Type guard for Track + */ +export function isTrack(value: unknown): value is Track { + return ( + typeof value === 'object' && + value !== null && + 'id' in value && + 'title' in value && + 'artist' in value && + 'duration' in value && + typeof (value as any).id === 'string' && + typeof (value as any).title === 'string' && + typeof (value as any).artist === 'string' && + typeof (value as any).duration === 'number' + ); +} + +/** + * Type guard for Playlist + */ +export function isPlaylist(value: unknown): value is Playlist { + return ( + typeof value === 'object' && + value !== null && + 'id' in value && + 'user_id' in value && + 'title' in value && + 'is_public' in value && + typeof (value as any).id === 'string' && + typeof (value as any).user_id === 'string' && + typeof (value as any).title === 'string' && + typeof (value as any).is_public === 'boolean' + ); +} + +/** + * Type guard for Conversation + */ +export function isConversation(value: unknown): value is Conversation { + return ( + typeof value === 'object' && + value !== null && + 'id' in value && + 'name' in value && + 'type' in value && + 'creator_id' in value && + typeof (value as any).id === 'string' && + typeof (value as any).name === 'string' && + typeof (value as any).type === 'string' && + typeof (value as any).creator_id === 'string' + ); +} + +/** + * Type guard for Message + */ +export function isMessage(value: unknown): value is Message { + return ( + typeof value === 'object' && + value !== null && + 'id' in value && + 'conversation_id' in value && + 'sender_id' in value && + 'content' in value && + typeof (value as any).id === 'string' && + typeof (value as any).conversation_id === 'string' && + typeof (value as any).sender_id === 'string' && + typeof (value as any).content === 'string' + ); +} + +/** + * Type guard for Session + */ +export function isSession(value: unknown): value is Session { + return ( + typeof value === 'object' && + value !== null && + 'id' in value && + 'user_id' in value && + 'ip_address' in value && + 'user_agent' in value && + 'expires_at' in value && + typeof (value as any).id === 'string' && + typeof (value as any).user_id === 'string' && + typeof (value as any).ip_address === 'string' && + typeof (value as any).user_agent === 'string' && + typeof (value as any).expires_at === 'string' + ); +} + +/** + * Type guard for AuditLog + */ +export function isAuditLog(value: unknown): value is AuditLog { + return ( + typeof value === 'object' && + value !== null && + 'id' in value && + 'action' in value && + 'resource' in value && + 'timestamp' in value && + typeof (value as any).id === 'string' && + typeof (value as any).action === 'string' && + typeof (value as any).resource === 'string' && + typeof (value as any).timestamp === 'string' + ); +} + +/** + * Type guard for Notification + */ +export function isNotification(value: unknown): value is Notification { + return ( + typeof value === 'object' && + value !== null && + 'id' in value && + 'user_id' in value && + 'type' in value && + 'content' in value && + 'read' in value && + typeof (value as any).id === 'string' && + typeof (value as any).user_id === 'string' && + typeof (value as any).type === 'string' && + typeof (value as any).content === 'string' && + typeof (value as any).read === 'boolean' + ); +} + +/** + * Type guard for ApiError + */ +export function isApiError(value: unknown): value is ApiError { + return ( + typeof value === 'object' && + value !== null && + 'code' in value && + 'message' in value && + 'timestamp' in value && + typeof (value as any).code === 'number' && + typeof (value as any).message === 'string' && + typeof (value as any).timestamp === 'string' + ); +} + +/** + * Type guard for ApiResponse + */ +export function isApiResponse(value: unknown): value is ApiResponse { + return ( + typeof value === 'object' && + value !== null && + 'success' in value && + typeof (value as any).success === 'boolean' + ); +} + +/** + * Type guard for PaginationData + */ +export function isPaginationData(value: unknown): value is PaginationData { + return ( + typeof value === 'object' && + value !== null && + 'page' in value && + 'limit' in value && + 'total' in value && + 'total_pages' in value && + 'has_next' in value && + 'has_prev' in value && + typeof (value as any).page === 'number' && + typeof (value as any).limit === 'number' && + typeof (value as any).total === 'number' && + typeof (value as any).total_pages === 'number' && + typeof (value as any).has_next === 'boolean' && + typeof (value as any).has_prev === 'boolean' + ); +} + +/** + * Type guard for array of Users + */ +export function isUserArray(value: unknown): value is User[] { + return Array.isArray(value) && value.every((item) => isUser(item)); +} + +/** + * Type guard for array of Tracks + */ +export function isTrackArray(value: unknown): value is Track[] { + return Array.isArray(value) && value.every((item) => isTrack(item)); +} + +/** + * Type guard for array of Playlists + */ +export function isPlaylistArray(value: unknown): value is Playlist[] { + return Array.isArray(value) && value.every((item) => isPlaylist(item)); +} + +/** + * Type guard for array of Conversations + */ +export function isConversationArray(value: unknown): value is Conversation[] { + return Array.isArray(value) && value.every((item) => isConversation(item)); +} + +/** + * Type guard for array of Messages + */ +export function isMessageArray(value: unknown): value is Message[] { + return Array.isArray(value) && value.every((item) => isMessage(item)); +} + +/** + * Type guard for array of Notifications + */ +export function isNotificationArray(value: unknown): value is Notification[] { + return Array.isArray(value) && value.every((item) => isNotification(item)); +} + +/** + * Type guard to check if value is a string UUID + */ +export function isUUID(value: unknown): value is string { + if (typeof value !== 'string') { + return false; + } + // UUID v4 pattern + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return uuidRegex.test(value); +} + +/** + * Type guard to check if value is a valid email + */ +export function isEmail(value: unknown): value is string { + if (typeof value !== 'string') { + return false; + } + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(value); +} + +/** + * Type guard to check if value is a valid ISO8601 date string + */ +export function isISO8601Date(value: unknown): value is string { + if (typeof value !== 'string') { + return false; + } + // ISO8601 date pattern + const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/; + if (!isoDateRegex.test(value)) { + return false; + } + // Try to parse as date + const date = new Date(value); + return !isNaN(date.getTime()); +} + +/** + * Type guard to check if value is a non-empty string + */ +export function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +/** + * Type guard to check if value is a positive number + */ +export function isPositiveNumber(value: unknown): value is number { + return typeof value === 'number' && value > 0 && !isNaN(value); +} + +/** + * Type guard to check if value is a non-negative number + */ +export function isNonNegativeNumber(value: unknown): value is number { + return typeof value === 'number' && value >= 0 && !isNaN(value); +} + +/** + * Type guard to check if value is a valid URL + */ +export function isURL(value: unknown): value is string { + if (typeof value !== 'string') { + return false; + } + try { + new URL(value); + return true; + } catch { + return false; + } +} + +/** + * Type guard to check if value is a plain object (not array, not null) + */ +export function isPlainObject(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.prototype.toString.call(value) === '[object Object]' + ); +} + +/** + * Type guard to check if value is an array of a specific type + * + * @param value - Value to check + * @param itemGuard - Type guard function for array items + * @returns True if value is an array and all items pass the guard + */ +export function isArrayOf( + value: unknown, + itemGuard: (item: unknown) => item is T, +): value is T[] { + return Array.isArray(value) && value.every(itemGuard); +} + +/** + * Type guard to check if value is not null or undefined + */ +export function isNotNull(value: T | null | undefined): value is T { + return value !== null && value !== undefined; +} + +/** + * Type guard to check if value is a defined (not undefined) + */ +export function isDefined(value: T | undefined): value is T { + return value !== undefined; +} + +/** + * Type guard to check if value is a number (including 0) + */ +export function isNumber(value: unknown): value is number { + return typeof value === 'number' && !isNaN(value); +} + +/** + * Type guard to check if value is a boolean + */ +export function isBoolean(value: unknown): value is boolean { + return typeof value === 'boolean'; +} + +/** + * Type guard to check if value is a string + */ +export function isString(value: unknown): value is string { + return typeof value === 'string'; +} +