Initial Upload
Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled
Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled
This commit is contained in:
29
packages/shared/package.json
Normal file
29
packages/shared/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@tracearr/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"clean": "rm -rf dist .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
314
packages/shared/src/constants.ts
Normal file
314
packages/shared/src/constants.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Shared constants for Tracearr
|
||||
*/
|
||||
|
||||
// Rule type definitions with default parameters
|
||||
export const RULE_DEFAULTS = {
|
||||
impossible_travel: {
|
||||
maxSpeedKmh: 500,
|
||||
ignoreVpnRanges: false,
|
||||
},
|
||||
simultaneous_locations: {
|
||||
minDistanceKm: 100,
|
||||
},
|
||||
device_velocity: {
|
||||
maxIps: 5,
|
||||
windowHours: 24,
|
||||
},
|
||||
concurrent_streams: {
|
||||
maxStreams: 3,
|
||||
},
|
||||
geo_restriction: {
|
||||
blockedCountries: [],
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Rule type display names
|
||||
export const RULE_DISPLAY_NAMES = {
|
||||
impossible_travel: 'Impossible Travel',
|
||||
simultaneous_locations: 'Simultaneous Locations',
|
||||
device_velocity: 'Device Velocity',
|
||||
concurrent_streams: 'Concurrent Streams',
|
||||
geo_restriction: 'Geo Restriction',
|
||||
} as const;
|
||||
|
||||
// Severity levels
|
||||
export const SEVERITY_LEVELS = {
|
||||
low: { label: 'Low', priority: 1 },
|
||||
warning: { label: 'Warning', priority: 2 },
|
||||
high: { label: 'High', priority: 3 },
|
||||
} as const;
|
||||
|
||||
// Type for severity priority numbers (1=low, 2=warning, 3=high)
|
||||
export type SeverityPriority = 1 | 2 | 3;
|
||||
|
||||
// Helper to get severity priority from string
|
||||
export function getSeverityPriority(severity: keyof typeof SEVERITY_LEVELS): SeverityPriority {
|
||||
return SEVERITY_LEVELS[severity]?.priority ?? 1;
|
||||
}
|
||||
|
||||
// WebSocket event names
|
||||
export const WS_EVENTS = {
|
||||
SESSION_STARTED: 'session:started',
|
||||
SESSION_STOPPED: 'session:stopped',
|
||||
SESSION_UPDATED: 'session:updated',
|
||||
VIOLATION_NEW: 'violation:new',
|
||||
STATS_UPDATED: 'stats:updated',
|
||||
IMPORT_PROGRESS: 'import:progress',
|
||||
SUBSCRIBE_SESSIONS: 'subscribe:sessions',
|
||||
UNSUBSCRIBE_SESSIONS: 'unsubscribe:sessions',
|
||||
} as const;
|
||||
|
||||
// Redis key prefixes
|
||||
export const REDIS_KEYS = {
|
||||
// Active sessions: SET of session IDs for atomic add/remove
|
||||
ACTIVE_SESSION_IDS: 'tracearr:sessions:active:ids',
|
||||
// Legacy: JSON array of sessions (deprecated, kept for migration)
|
||||
ACTIVE_SESSIONS: 'tracearr:sessions:active',
|
||||
// Individual session data
|
||||
SESSION_BY_ID: (id: string) => `tracearr:sessions:${id}`,
|
||||
USER_SESSIONS: (userId: string) => `tracearr:users:${userId}:sessions`,
|
||||
DASHBOARD_STATS: 'tracearr:stats:dashboard',
|
||||
RATE_LIMIT_LOGIN: (ip: string) => `tracearr:ratelimit:login:${ip}`,
|
||||
RATE_LIMIT_MOBILE_PAIR: (ip: string) => `tracearr:ratelimit:mobile:pair:${ip}`,
|
||||
RATE_LIMIT_MOBILE_REFRESH: (ip: string) => `tracearr:ratelimit:mobile:refresh:${ip}`,
|
||||
SERVER_HEALTH: (serverId: string) => `tracearr:servers:${serverId}:health`,
|
||||
PUBSUB_EVENTS: 'tracearr:events',
|
||||
// Notification rate limiting (sliding window counters)
|
||||
PUSH_RATE_MINUTE: (sessionId: string) => `tracearr:push:rate:minute:${sessionId}`,
|
||||
PUSH_RATE_HOUR: (sessionId: string) => `tracearr:push:rate:hour:${sessionId}`,
|
||||
// Location stats filter caching (includes serverIds hash for proper scoping)
|
||||
LOCATION_FILTERS: (userId: string, serverIds: string[]) => {
|
||||
// Sort and hash serverIds for stable cache key
|
||||
const serverHash = serverIds.length > 0 ? serverIds.slice().sort().join(',') : 'all';
|
||||
return `tracearr:filters:locations:${userId}:${serverHash}`;
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Cache TTLs in seconds
|
||||
export const CACHE_TTL = {
|
||||
DASHBOARD_STATS: 60,
|
||||
ACTIVE_SESSIONS: 300,
|
||||
USER_SESSIONS: 3600,
|
||||
RATE_LIMIT: 900,
|
||||
SERVER_HEALTH: 600, // 10 minutes - servers marked unhealthy if no update
|
||||
LOCATION_FILTERS: 300, // 5 minutes - filter options change infrequently
|
||||
} as const;
|
||||
|
||||
// Notification event types (must match NotificationEventType in types.ts)
|
||||
export const NOTIFICATION_EVENTS = {
|
||||
VIOLATION_DETECTED: 'violation_detected',
|
||||
STREAM_STARTED: 'stream_started',
|
||||
STREAM_STOPPED: 'stream_stopped',
|
||||
CONCURRENT_STREAMS: 'concurrent_streams',
|
||||
NEW_DEVICE: 'new_device',
|
||||
TRUST_SCORE_CHANGED: 'trust_score_changed',
|
||||
SERVER_DOWN: 'server_down',
|
||||
SERVER_UP: 'server_up',
|
||||
} as const;
|
||||
|
||||
// API version
|
||||
export const API_VERSION = 'v1';
|
||||
export const API_BASE_PATH = `/api/${API_VERSION}`;
|
||||
|
||||
// JWT configuration
|
||||
export const JWT_CONFIG = {
|
||||
ACCESS_TOKEN_EXPIRY: '48h',
|
||||
REFRESH_TOKEN_EXPIRY: '30d',
|
||||
ALGORITHM: 'HS256',
|
||||
} as const;
|
||||
|
||||
// Polling intervals in milliseconds
|
||||
export const POLLING_INTERVALS = {
|
||||
SESSIONS: 15000,
|
||||
STATS_REFRESH: 60000,
|
||||
SERVER_HEALTH: 30000,
|
||||
// Reconciliation interval when SSE is active (fallback check)
|
||||
SSE_RECONCILIATION: 30 * 1000, // 30 seconds
|
||||
} as const;
|
||||
|
||||
// SSE (Server-Sent Events) configuration
|
||||
export const SSE_CONFIG = {
|
||||
// Reconnection settings
|
||||
INITIAL_RETRY_DELAY_MS: 1000,
|
||||
MAX_RETRY_DELAY_MS: 30000,
|
||||
RETRY_MULTIPLIER: 2,
|
||||
MAX_RETRIES: 10,
|
||||
// Heartbeat/keepalive - how long without events before assuming connection died
|
||||
// Plex sends ping events every 10 seconds, so 30s = miss 3 pings = dead
|
||||
HEARTBEAT_TIMEOUT_MS: 30000, // 30 seconds
|
||||
// When to fall back to polling
|
||||
FALLBACK_THRESHOLD: 5, // consecutive failures before fallback
|
||||
} as const;
|
||||
|
||||
// Plex SSE notification types (from /:/eventsource/notifications)
|
||||
export const PLEX_SSE_EVENTS = {
|
||||
// Session-related
|
||||
PLAYING: 'playing',
|
||||
PROGRESS: 'progress',
|
||||
STOPPED: 'stopped',
|
||||
PAUSED: 'paused',
|
||||
RESUMED: 'resumed',
|
||||
// Library updates
|
||||
LIBRARY_UPDATE: 'library.update',
|
||||
LIBRARY_SCAN: 'library.scan',
|
||||
// Server status
|
||||
SERVER_BACKUP: 'server.backup',
|
||||
SERVER_UPDATE: 'server.update',
|
||||
// Activity
|
||||
ACTIVITY: 'activity',
|
||||
// Transcoder
|
||||
TRANSCODE_SESSION_UPDATE: 'transcodeSession.update',
|
||||
TRANSCODE_SESSION_END: 'transcodeSession.end',
|
||||
} as const;
|
||||
|
||||
// SSE connection states
|
||||
export const SSE_STATE = {
|
||||
CONNECTING: 'connecting',
|
||||
CONNECTED: 'connected',
|
||||
RECONNECTING: 'reconnecting',
|
||||
DISCONNECTED: 'disconnected',
|
||||
FALLBACK: 'fallback', // Using polling as fallback
|
||||
} as const;
|
||||
|
||||
// Pagination defaults
|
||||
export const PAGINATION = {
|
||||
DEFAULT_PAGE: 1,
|
||||
DEFAULT_PAGE_SIZE: 20,
|
||||
MAX_PAGE_SIZE: 100,
|
||||
} as const;
|
||||
|
||||
// GeoIP configuration
|
||||
export const GEOIP_CONFIG = {
|
||||
EARTH_RADIUS_KM: 6371,
|
||||
DEFAULT_UNKNOWN_LOCATION: 'Unknown',
|
||||
} as const;
|
||||
|
||||
// Unit conversion constants
|
||||
export const UNIT_CONVERSION = {
|
||||
KM_TO_MILES: 0.621371,
|
||||
MILES_TO_KM: 1.60934,
|
||||
} as const;
|
||||
|
||||
// Unit system types and utilities
|
||||
export type UnitSystem = 'metric' | 'imperial';
|
||||
|
||||
/**
|
||||
* Convert kilometers to miles
|
||||
*/
|
||||
export function kmToMiles(km: number): number {
|
||||
return km * UNIT_CONVERSION.KM_TO_MILES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert miles to kilometers
|
||||
*/
|
||||
export function milesToKm(miles: number): number {
|
||||
return miles * UNIT_CONVERSION.MILES_TO_KM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format distance based on unit system
|
||||
* @param km - Distance in kilometers (internal unit)
|
||||
* @param unitSystem - User's preferred unit system
|
||||
* @param decimals - Number of decimal places (default: 0)
|
||||
*/
|
||||
export function formatDistance(km: number, unitSystem: UnitSystem, decimals = 0): string {
|
||||
if (unitSystem === 'imperial') {
|
||||
const miles = kmToMiles(km);
|
||||
return `${miles.toFixed(decimals)} mi`;
|
||||
}
|
||||
return `${km.toFixed(decimals)} km`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format speed based on unit system
|
||||
* @param kmh - Speed in km/h (internal unit)
|
||||
* @param unitSystem - User's preferred unit system
|
||||
* @param decimals - Number of decimal places (default: 0)
|
||||
*/
|
||||
export function formatSpeed(kmh: number, unitSystem: UnitSystem, decimals = 0): string {
|
||||
if (unitSystem === 'imperial') {
|
||||
const mph = kmToMiles(kmh);
|
||||
return `${mph.toFixed(decimals)} mph`;
|
||||
}
|
||||
return `${kmh.toFixed(decimals)} km/h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get distance unit label
|
||||
*/
|
||||
export function getDistanceUnit(unitSystem: UnitSystem): string {
|
||||
return unitSystem === 'imperial' ? 'mi' : 'km';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get speed unit label
|
||||
*/
|
||||
export function getSpeedUnit(unitSystem: UnitSystem): string {
|
||||
return unitSystem === 'imperial' ? 'mph' : 'km/h';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert display value to internal metric value (for form inputs)
|
||||
* @param value - Value in user's preferred unit
|
||||
* @param unitSystem - User's preferred unit system
|
||||
* @returns Value in kilometers (internal unit)
|
||||
*/
|
||||
export function toMetricDistance(value: number, unitSystem: UnitSystem): number {
|
||||
if (unitSystem === 'imperial') {
|
||||
return milesToKm(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert internal metric value to display value (for form inputs)
|
||||
* @param km - Value in kilometers (internal unit)
|
||||
* @param unitSystem - User's preferred unit system
|
||||
* @returns Value in user's preferred unit
|
||||
*/
|
||||
export function fromMetricDistance(km: number, unitSystem: UnitSystem): number {
|
||||
if (unitSystem === 'imperial') {
|
||||
return kmToMiles(km);
|
||||
}
|
||||
return km;
|
||||
}
|
||||
|
||||
// Time constants in milliseconds (avoid magic numbers)
|
||||
export const TIME_MS = {
|
||||
SECOND: 1000,
|
||||
MINUTE: 60 * 1000,
|
||||
HOUR: 60 * 60 * 1000,
|
||||
DAY: 24 * 60 * 60 * 1000,
|
||||
WEEK: 7 * 24 * 60 * 60 * 1000,
|
||||
} as const;
|
||||
|
||||
// Server resource statistics configuration (CPU, RAM)
|
||||
// Used with Plex's undocumented /statistics/resources endpoint
|
||||
export const SERVER_STATS_CONFIG = {
|
||||
// Poll interval in seconds (how often we fetch new data)
|
||||
POLL_INTERVAL_SECONDS: 6,
|
||||
// Timespan parameter for Plex API (MUST be 6 - other values return empty!)
|
||||
TIMESPAN_SECONDS: 6,
|
||||
// Fixed 2-minute window (20 data points at 6s intervals)
|
||||
WINDOW_SECONDS: 120,
|
||||
// Data points to display (2 min / 6s = 20 points)
|
||||
DATA_POINTS: 20,
|
||||
} as const;
|
||||
|
||||
// Session limits
|
||||
export const SESSION_LIMITS = {
|
||||
MAX_RECENT_PER_USER: 100,
|
||||
RESUME_WINDOW_HOURS: 24,
|
||||
// Watch completion threshold - 85% is industry standard
|
||||
WATCH_COMPLETION_THRESHOLD: 0.85,
|
||||
// Stale session timeout - force stop after 5 minutes of no updates
|
||||
STALE_SESSION_TIMEOUT_SECONDS: 300,
|
||||
// Minimum play time to record session - filter short plays (2 minutes default)
|
||||
MIN_PLAY_TIME_MS: 120 * 1000,
|
||||
// Continued session threshold - max gap to consider a "resume" vs new watch
|
||||
CONTINUED_SESSION_THRESHOLD_MS: 60 * 1000,
|
||||
// Stale session sweep interval - how often to check for stale sessions (1 minute)
|
||||
STALE_SWEEP_INTERVAL_MS: 60 * 1000,
|
||||
} as const;
|
||||
207
packages/shared/src/index.ts
Normal file
207
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @tracearr/shared - Shared types, schemas, and constants
|
||||
*/
|
||||
|
||||
// Type exports
|
||||
export type {
|
||||
// Server
|
||||
ServerType,
|
||||
Server,
|
||||
// User
|
||||
User,
|
||||
ServerUser,
|
||||
ServerUserWithIdentity,
|
||||
ServerUserDetail,
|
||||
ServerUserFullDetail,
|
||||
ViolationSummary,
|
||||
UserRole,
|
||||
AuthUser,
|
||||
UserLocation,
|
||||
UserDevice,
|
||||
// Session
|
||||
SessionState,
|
||||
MediaType,
|
||||
Session,
|
||||
SessionWithDetails,
|
||||
ActiveSession,
|
||||
// Rule
|
||||
RuleType,
|
||||
ImpossibleTravelParams,
|
||||
SimultaneousLocationsParams,
|
||||
DeviceVelocityParams,
|
||||
ConcurrentStreamsParams,
|
||||
GeoRestrictionParams,
|
||||
RuleParams,
|
||||
Rule,
|
||||
// Violation
|
||||
ViolationSeverity,
|
||||
Violation,
|
||||
ViolationWithDetails,
|
||||
ViolationSessionInfo,
|
||||
// Stats
|
||||
DashboardStats,
|
||||
PlayStats,
|
||||
UserStats,
|
||||
LocationStats,
|
||||
LocationStatsSummary,
|
||||
LocationStatsResponse,
|
||||
LibraryStats,
|
||||
DayOfWeekStats,
|
||||
HourOfDayStats,
|
||||
QualityStats,
|
||||
TopUserStats,
|
||||
TopContentStats,
|
||||
PlatformStats,
|
||||
// Server resource stats
|
||||
ServerResourceDataPoint,
|
||||
ServerResourceStats,
|
||||
// Settings
|
||||
Settings,
|
||||
WebhookFormat,
|
||||
UnitSystem,
|
||||
// Tautulli import
|
||||
TautulliImportProgress,
|
||||
TautulliImportResult,
|
||||
// WebSocket
|
||||
ServerToClientEvents,
|
||||
ClientToServerEvents,
|
||||
// API
|
||||
PaginatedResponse,
|
||||
ApiError,
|
||||
// Mobile
|
||||
MobileToken,
|
||||
MobileSession,
|
||||
MobileConfig,
|
||||
MobilePairRequest,
|
||||
MobilePairResponse,
|
||||
MobilePairTokenResponse,
|
||||
MobileQRPayload,
|
||||
NotificationEventType,
|
||||
NotificationPreferences,
|
||||
RateLimitStatus,
|
||||
NotificationPreferencesWithStatus,
|
||||
NotificationChannel,
|
||||
NotificationChannelRouting,
|
||||
EncryptedPushPayload,
|
||||
PushNotificationPayload,
|
||||
// SSE (Server-Sent Events)
|
||||
SSEConnectionState,
|
||||
PlexSSENotification,
|
||||
PlexPlaySessionNotification,
|
||||
PlexActivityNotification,
|
||||
PlexStatusNotification,
|
||||
PlexTranscodeNotification,
|
||||
SSEConnectionStatus,
|
||||
// Termination logs
|
||||
TerminationTrigger,
|
||||
TerminationLogWithDetails,
|
||||
// Plex server discovery
|
||||
PlexDiscoveredConnection,
|
||||
PlexDiscoveredServer,
|
||||
PlexAvailableServersResponse,
|
||||
} from './types.js';
|
||||
|
||||
// Schema exports
|
||||
export {
|
||||
// Common
|
||||
uuidSchema,
|
||||
paginationSchema,
|
||||
// Auth
|
||||
loginSchema,
|
||||
callbackSchema,
|
||||
// Server
|
||||
createServerSchema,
|
||||
serverIdParamSchema,
|
||||
// User
|
||||
updateUserSchema,
|
||||
updateUserIdentitySchema,
|
||||
userIdParamSchema,
|
||||
// Session
|
||||
sessionQuerySchema,
|
||||
sessionIdParamSchema,
|
||||
terminateSessionBodySchema,
|
||||
// Rule
|
||||
impossibleTravelParamsSchema,
|
||||
simultaneousLocationsParamsSchema,
|
||||
deviceVelocityParamsSchema,
|
||||
concurrentStreamsParamsSchema,
|
||||
geoRestrictionParamsSchema,
|
||||
ruleParamsSchema,
|
||||
createRuleSchema,
|
||||
updateRuleSchema,
|
||||
ruleIdParamSchema,
|
||||
// Violation
|
||||
violationQuerySchema,
|
||||
violationIdParamSchema,
|
||||
// Stats
|
||||
serverIdFilterSchema,
|
||||
statsQuerySchema,
|
||||
locationStatsQuerySchema,
|
||||
// Settings
|
||||
updateSettingsSchema,
|
||||
// Tautulli import
|
||||
tautulliImportSchema,
|
||||
} from './schemas.js';
|
||||
|
||||
// Schema input type exports
|
||||
export type {
|
||||
LoginInput,
|
||||
CallbackInput,
|
||||
CreateServerInput,
|
||||
UpdateUserInput,
|
||||
UpdateUserIdentityInput,
|
||||
SessionQueryInput,
|
||||
CreateRuleInput,
|
||||
UpdateRuleInput,
|
||||
ViolationQueryInput,
|
||||
ServerIdFilterInput,
|
||||
StatsQueryInput,
|
||||
LocationStatsQueryInput,
|
||||
UpdateSettingsInput,
|
||||
TautulliImportInput,
|
||||
} from './schemas.js';
|
||||
|
||||
// Constant exports
|
||||
export {
|
||||
RULE_DEFAULTS,
|
||||
RULE_DISPLAY_NAMES,
|
||||
SEVERITY_LEVELS,
|
||||
getSeverityPriority,
|
||||
type SeverityPriority,
|
||||
WS_EVENTS,
|
||||
REDIS_KEYS,
|
||||
CACHE_TTL,
|
||||
NOTIFICATION_EVENTS,
|
||||
API_VERSION,
|
||||
API_BASE_PATH,
|
||||
JWT_CONFIG,
|
||||
POLLING_INTERVALS,
|
||||
PAGINATION,
|
||||
GEOIP_CONFIG,
|
||||
TIME_MS,
|
||||
SESSION_LIMITS,
|
||||
SERVER_STATS_CONFIG,
|
||||
// SSE
|
||||
SSE_CONFIG,
|
||||
PLEX_SSE_EVENTS,
|
||||
SSE_STATE,
|
||||
// Unit conversion
|
||||
UNIT_CONVERSION,
|
||||
kmToMiles,
|
||||
milesToKm,
|
||||
formatDistance,
|
||||
formatSpeed,
|
||||
getDistanceUnit,
|
||||
getSpeedUnit,
|
||||
toMetricDistance,
|
||||
fromMetricDistance,
|
||||
} from './constants.js';
|
||||
|
||||
// Role helper exports
|
||||
export {
|
||||
ROLE_PERMISSIONS,
|
||||
canLogin,
|
||||
hasMinRole,
|
||||
isOwner,
|
||||
isActive,
|
||||
} from './types.js';
|
||||
253
packages/shared/src/schemas.ts
Normal file
253
packages/shared/src/schemas.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Zod validation schemas for API requests
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// Common schemas
|
||||
export const uuidSchema = z.uuid();
|
||||
export const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
pageSize: z.coerce.number().int().positive().max(100).default(20),
|
||||
});
|
||||
|
||||
// Auth schemas
|
||||
export const loginSchema = z.object({
|
||||
serverType: z.enum(['plex', 'jellyfin', 'emby']),
|
||||
returnUrl: z.url().optional(),
|
||||
});
|
||||
|
||||
export const callbackSchema = z.object({
|
||||
code: z.string().optional(),
|
||||
token: z.string().optional(),
|
||||
serverType: z.enum(['plex', 'jellyfin', 'emby']),
|
||||
});
|
||||
|
||||
// Server schemas
|
||||
export const createServerSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
type: z.enum(['plex', 'jellyfin', 'emby']),
|
||||
url: z.url(),
|
||||
token: z.string().min(1),
|
||||
});
|
||||
|
||||
export const serverIdParamSchema = z.object({
|
||||
id: uuidSchema,
|
||||
});
|
||||
|
||||
// User schemas
|
||||
export const updateUserSchema = z.object({
|
||||
allowGuest: z.boolean().optional(),
|
||||
trustScore: z.number().int().min(0).max(100).optional(),
|
||||
});
|
||||
|
||||
export const updateUserIdentitySchema = z.object({
|
||||
name: z.string().max(255).nullable().optional(),
|
||||
});
|
||||
|
||||
export type UpdateUserIdentityInput = z.infer<typeof updateUserIdentitySchema>;
|
||||
|
||||
export const userIdParamSchema = z.object({
|
||||
id: uuidSchema,
|
||||
});
|
||||
|
||||
// Session schemas
|
||||
export const sessionQuerySchema = paginationSchema.extend({
|
||||
serverUserId: uuidSchema.optional(),
|
||||
serverId: uuidSchema.optional(),
|
||||
state: z.enum(['playing', 'paused', 'stopped']).optional(),
|
||||
mediaType: z.enum(['movie', 'episode', 'track']).optional(),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
export const sessionIdParamSchema = z.object({
|
||||
id: uuidSchema,
|
||||
});
|
||||
|
||||
// Session termination schema
|
||||
export const terminateSessionBodySchema = z.object({
|
||||
/** Optional message to display to user (Plex only, ignored by Jellyfin/Emby) */
|
||||
reason: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
// Rule schemas
|
||||
export const impossibleTravelParamsSchema = z.object({
|
||||
maxSpeedKmh: z.number().positive().default(500),
|
||||
ignoreVpnRanges: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const simultaneousLocationsParamsSchema = z.object({
|
||||
minDistanceKm: z.number().positive().default(100),
|
||||
});
|
||||
|
||||
export const deviceVelocityParamsSchema = z.object({
|
||||
maxIps: z.number().int().positive().default(5),
|
||||
windowHours: z.number().int().positive().default(24),
|
||||
});
|
||||
|
||||
export const concurrentStreamsParamsSchema = z.object({
|
||||
maxStreams: z.number().int().positive().default(3),
|
||||
});
|
||||
|
||||
export const geoRestrictionParamsSchema = z.object({
|
||||
blockedCountries: z.array(z.string().length(2)).default([]),
|
||||
});
|
||||
|
||||
export const ruleParamsSchema = z.union([
|
||||
impossibleTravelParamsSchema,
|
||||
simultaneousLocationsParamsSchema,
|
||||
deviceVelocityParamsSchema,
|
||||
concurrentStreamsParamsSchema,
|
||||
geoRestrictionParamsSchema,
|
||||
]);
|
||||
|
||||
export const createRuleSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
type: z.enum([
|
||||
'impossible_travel',
|
||||
'simultaneous_locations',
|
||||
'device_velocity',
|
||||
'concurrent_streams',
|
||||
'geo_restriction',
|
||||
]),
|
||||
params: z.record(z.string(), z.unknown()),
|
||||
serverUserId: uuidSchema.nullable().default(null),
|
||||
isActive: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const updateRuleSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
params: z.record(z.string(), z.unknown()).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const ruleIdParamSchema = z.object({
|
||||
id: uuidSchema,
|
||||
});
|
||||
|
||||
// Violation schemas
|
||||
export const violationQuerySchema = paginationSchema.extend({
|
||||
serverId: uuidSchema.optional(),
|
||||
serverUserId: uuidSchema.optional(),
|
||||
ruleId: uuidSchema.optional(),
|
||||
severity: z.enum(['low', 'warning', 'high']).optional(),
|
||||
acknowledged: z.coerce.boolean().optional(),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
export const violationIdParamSchema = z.object({
|
||||
id: uuidSchema,
|
||||
});
|
||||
|
||||
// Stats schemas
|
||||
export const serverIdFilterSchema = z.object({
|
||||
serverId: uuidSchema.optional(),
|
||||
});
|
||||
|
||||
export const statsQuerySchema = z
|
||||
.object({
|
||||
period: z.enum(['day', 'week', 'month', 'year', 'all', 'custom']).default('week'),
|
||||
startDate: z.iso.datetime().optional(),
|
||||
endDate: z.iso.datetime().optional(),
|
||||
serverId: uuidSchema.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// Custom period requires both dates
|
||||
if (data.period === 'custom') {
|
||||
return data.startDate && data.endDate;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: 'Custom period requires startDate and endDate' }
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
// If dates provided, start must be before end
|
||||
if (data.startDate && data.endDate) {
|
||||
return new Date(data.startDate) < new Date(data.endDate);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: 'startDate must be before endDate' }
|
||||
);
|
||||
|
||||
// Location stats with full filtering - uses same period system as statsQuerySchema
|
||||
export const locationStatsQuerySchema = z
|
||||
.object({
|
||||
period: z.enum(['day', 'week', 'month', 'year', 'all', 'custom']).default('month'),
|
||||
startDate: z.iso.datetime().optional(),
|
||||
endDate: z.iso.datetime().optional(),
|
||||
serverUserId: uuidSchema.optional(),
|
||||
serverId: uuidSchema.optional(),
|
||||
mediaType: z.enum(['movie', 'episode', 'track']).optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.period === 'custom') {
|
||||
return data.startDate && data.endDate;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: 'Custom period requires startDate and endDate' }
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.startDate && data.endDate) {
|
||||
return new Date(data.startDate) < new Date(data.endDate);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: 'startDate must be before endDate' }
|
||||
);
|
||||
|
||||
// Webhook format enum
|
||||
export const webhookFormatSchema = z.enum(['json', 'ntfy', 'apprise']);
|
||||
|
||||
// Unit system enum for display preferences
|
||||
export const unitSystemSchema = z.enum(['metric', 'imperial']);
|
||||
|
||||
// Settings schemas
|
||||
export const updateSettingsSchema = z.object({
|
||||
allowGuestAccess: z.boolean().optional(),
|
||||
// Display preferences
|
||||
unitSystem: unitSystemSchema.optional(),
|
||||
discordWebhookUrl: z.url().nullable().optional(),
|
||||
customWebhookUrl: z.url().nullable().optional(),
|
||||
webhookFormat: webhookFormatSchema.nullable().optional(),
|
||||
ntfyTopic: z.string().max(200).nullable().optional(),
|
||||
// Poller settings
|
||||
pollerEnabled: z.boolean().optional(),
|
||||
pollerIntervalMs: z.number().int().min(5000).max(300000).optional(),
|
||||
// Tautulli integration
|
||||
tautulliUrl: z.url().nullable().optional(),
|
||||
tautulliApiKey: z.string().nullable().optional(),
|
||||
// Network/access settings
|
||||
externalUrl: z.url().nullable().optional(),
|
||||
basePath: z.string().max(100).optional(),
|
||||
trustProxy: z.boolean().optional(),
|
||||
// Authentication settings
|
||||
primaryAuthMethod: z.enum(['jellyfin', 'local']).optional(),
|
||||
});
|
||||
|
||||
// Tautulli import schemas
|
||||
export const tautulliImportSchema = z.object({
|
||||
serverId: uuidSchema, // Which Tracearr server to import into
|
||||
});
|
||||
|
||||
// Type exports from schemas
|
||||
export type LoginInput = z.infer<typeof loginSchema>;
|
||||
export type CallbackInput = z.infer<typeof callbackSchema>;
|
||||
export type CreateServerInput = z.infer<typeof createServerSchema>;
|
||||
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
|
||||
export type SessionQueryInput = z.infer<typeof sessionQuerySchema>;
|
||||
export type CreateRuleInput = z.infer<typeof createRuleSchema>;
|
||||
export type UpdateRuleInput = z.infer<typeof updateRuleSchema>;
|
||||
export type ViolationQueryInput = z.infer<typeof violationQuerySchema>;
|
||||
export type ServerIdFilterInput = z.infer<typeof serverIdFilterSchema>;
|
||||
export type StatsQueryInput = z.infer<typeof statsQuerySchema>;
|
||||
export type LocationStatsQueryInput = z.infer<typeof locationStatsQuerySchema>;
|
||||
export type UpdateSettingsInput = z.infer<typeof updateSettingsSchema>;
|
||||
export type TautulliImportInput = z.infer<typeof tautulliImportSchema>;
|
||||
910
packages/shared/src/types.ts
Normal file
910
packages/shared/src/types.ts
Normal file
@@ -0,0 +1,910 @@
|
||||
/**
|
||||
* Core type definitions for Tracearr
|
||||
*/
|
||||
|
||||
// User role - combined permission level and account status
|
||||
// Can log in: owner, admin, viewer
|
||||
// Cannot log in: member (default for synced users), disabled, pending
|
||||
export type UserRole = 'owner' | 'admin' | 'viewer' | 'member' | 'disabled' | 'pending';
|
||||
|
||||
// Role permission hierarchy (higher = more permissions)
|
||||
export const ROLE_PERMISSIONS: Record<UserRole, number> = {
|
||||
owner: 4,
|
||||
admin: 3,
|
||||
viewer: 2,
|
||||
member: 1, // Synced from media server, no Tracearr login until promoted
|
||||
disabled: 0,
|
||||
pending: 0,
|
||||
} as const;
|
||||
|
||||
// Roles that can log into Tracearr
|
||||
const LOGIN_ROLES: UserRole[] = ['owner', 'admin', 'viewer'];
|
||||
|
||||
// Role helper functions
|
||||
export const canLogin = (role: UserRole): boolean =>
|
||||
LOGIN_ROLES.includes(role);
|
||||
|
||||
export const hasMinRole = (
|
||||
userRole: UserRole,
|
||||
required: 'owner' | 'admin' | 'viewer'
|
||||
): boolean => ROLE_PERMISSIONS[userRole] >= ROLE_PERMISSIONS[required];
|
||||
|
||||
export const isOwner = (role: UserRole): boolean => role === 'owner';
|
||||
export const isActive = (role: UserRole): boolean => canLogin(role);
|
||||
|
||||
// Server types
|
||||
export type ServerType = 'plex' | 'jellyfin' | 'emby';
|
||||
|
||||
export interface Server {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ServerType;
|
||||
url: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// User types - Identity layer (the real human)
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string; // Login identifier (unique)
|
||||
name: string | null; // Display name (optional)
|
||||
thumbnail: string | null;
|
||||
email: string | null;
|
||||
role: UserRole; // Combined permission level and account status
|
||||
aggregateTrustScore: number;
|
||||
totalViolations: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Server User types - Account on a specific media server
|
||||
export interface ServerUser {
|
||||
id: string;
|
||||
userId: string;
|
||||
serverId: string;
|
||||
externalId: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
thumbUrl: string | null;
|
||||
isServerAdmin: boolean;
|
||||
trustScore: number;
|
||||
sessionCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Server User with identity info - returned by /users API endpoints
|
||||
export interface ServerUserWithIdentity extends ServerUser {
|
||||
serverName: string;
|
||||
identityName: string | null;
|
||||
role: UserRole; // From linked User identity
|
||||
}
|
||||
|
||||
// Server User detail with stats - returned by GET /users/:id
|
||||
export interface ServerUserDetail extends ServerUserWithIdentity {
|
||||
stats: {
|
||||
totalSessions: number;
|
||||
totalWatchTime: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Violation summary for embedded responses (simpler than ViolationWithDetails)
|
||||
export interface ViolationSummary {
|
||||
id: string;
|
||||
ruleId: string;
|
||||
rule: {
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
serverUserId: string;
|
||||
sessionId: string;
|
||||
mediaTitle: string | null;
|
||||
severity: string;
|
||||
data: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
acknowledgedAt: Date | null;
|
||||
}
|
||||
|
||||
// Full user detail with all related data - returned by GET /users/:id/full
|
||||
// This aggregate response reduces 6 API calls to 1 for the UserDetail page
|
||||
export interface ServerUserFullDetail {
|
||||
user: ServerUserDetail;
|
||||
sessions: {
|
||||
data: Session[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
locations: UserLocation[];
|
||||
devices: UserDevice[];
|
||||
violations: {
|
||||
data: ViolationSummary[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
terminations: {
|
||||
data: TerminationLogWithDetails[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
serverIds: string[];
|
||||
mobile?: boolean; // True for mobile app tokens
|
||||
deviceId?: string; // Device identifier for mobile tokens
|
||||
}
|
||||
|
||||
// Session types
|
||||
export type SessionState = 'playing' | 'paused' | 'stopped';
|
||||
export type MediaType = 'movie' | 'episode' | 'track';
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
serverId: string;
|
||||
serverUserId: string;
|
||||
sessionKey: string;
|
||||
state: SessionState;
|
||||
mediaType: MediaType;
|
||||
mediaTitle: string;
|
||||
// Enhanced media metadata for episodes
|
||||
grandparentTitle: string | null; // Show name (for episodes)
|
||||
seasonNumber: number | null; // Season number (for episodes)
|
||||
episodeNumber: number | null; // Episode number (for episodes)
|
||||
year: number | null; // Release year
|
||||
thumbPath: string | null; // Poster path (e.g., /library/metadata/123/thumb)
|
||||
ratingKey: string | null; // Plex/Jellyfin media identifier
|
||||
externalSessionId: string | null; // External reference for deduplication
|
||||
startedAt: Date;
|
||||
stoppedAt: Date | null;
|
||||
durationMs: number | null; // Actual watch duration (excludes paused time)
|
||||
totalDurationMs: number | null; // Total media length
|
||||
progressMs: number | null; // Current playback position
|
||||
// Pause tracking - accumulates total paused time across pause/resume cycles
|
||||
lastPausedAt: Date | null; // When current pause started (null if not paused)
|
||||
pausedDurationMs: number; // Accumulated pause time in milliseconds
|
||||
// Session grouping for "resume where left off" tracking
|
||||
referenceId: string | null; // Links to first session in resume chain
|
||||
watched: boolean; // True if user watched 80%+ of content
|
||||
// Network and device info
|
||||
ipAddress: string;
|
||||
geoCity: string | null;
|
||||
geoRegion: string | null; // State/province/subdivision
|
||||
geoCountry: string | null;
|
||||
geoLat: number | null;
|
||||
geoLon: number | null;
|
||||
playerName: string | null; // Friendly device name
|
||||
deviceId: string | null; // Unique device identifier (machineIdentifier)
|
||||
product: string | null; // Product/app name (e.g., "Plex for iOS")
|
||||
device: string | null; // Device type (e.g., "iPhone")
|
||||
platform: string | null;
|
||||
quality: string | null;
|
||||
isTranscode: boolean;
|
||||
bitrate: number | null;
|
||||
}
|
||||
|
||||
export interface ActiveSession extends Session {
|
||||
user: Pick<ServerUser, 'id' | 'username' | 'thumbUrl'> & { identityName: string | null };
|
||||
server: Pick<Server, 'id' | 'name' | 'type'>;
|
||||
}
|
||||
|
||||
// Session with user/server details (from paginated API)
|
||||
// When returned from history queries, sessions are grouped by reference_id
|
||||
// Note: The single session endpoint (GET /sessions/:id) returns totalDurationMs,
|
||||
// while paginated list queries aggregate duration and don't include it.
|
||||
export interface SessionWithDetails extends Omit<Session, 'ratingKey' | 'externalSessionId'> {
|
||||
username: string;
|
||||
userThumb: string | null;
|
||||
serverName: string;
|
||||
serverType: ServerType;
|
||||
// Number of pause/resume segments in this grouped play (1 = no pauses)
|
||||
segmentCount?: number;
|
||||
}
|
||||
|
||||
// Rule types
|
||||
export type RuleType =
|
||||
| 'impossible_travel'
|
||||
| 'simultaneous_locations'
|
||||
| 'device_velocity'
|
||||
| 'concurrent_streams'
|
||||
| 'geo_restriction';
|
||||
|
||||
export interface ImpossibleTravelParams {
|
||||
maxSpeedKmh: number;
|
||||
ignoreVpnRanges?: boolean;
|
||||
}
|
||||
|
||||
export interface SimultaneousLocationsParams {
|
||||
minDistanceKm: number;
|
||||
}
|
||||
|
||||
export interface DeviceVelocityParams {
|
||||
maxIps: number;
|
||||
windowHours: number;
|
||||
}
|
||||
|
||||
export interface ConcurrentStreamsParams {
|
||||
maxStreams: number;
|
||||
}
|
||||
|
||||
export interface GeoRestrictionParams {
|
||||
blockedCountries: string[];
|
||||
}
|
||||
|
||||
export type RuleParams =
|
||||
| ImpossibleTravelParams
|
||||
| SimultaneousLocationsParams
|
||||
| DeviceVelocityParams
|
||||
| ConcurrentStreamsParams
|
||||
| GeoRestrictionParams;
|
||||
|
||||
export interface Rule {
|
||||
id: string;
|
||||
name: string;
|
||||
type: RuleType;
|
||||
params: RuleParams;
|
||||
serverUserId: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Violation types
|
||||
export type ViolationSeverity = 'low' | 'warning' | 'high';
|
||||
|
||||
export interface Violation {
|
||||
id: string;
|
||||
ruleId: string;
|
||||
serverUserId: string;
|
||||
sessionId: string;
|
||||
severity: ViolationSeverity;
|
||||
data: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
acknowledgedAt: Date | null;
|
||||
}
|
||||
|
||||
// Session info for violations (used in both session and relatedSessions)
|
||||
export interface ViolationSessionInfo {
|
||||
id: string;
|
||||
mediaTitle: string;
|
||||
mediaType: MediaType;
|
||||
grandparentTitle: string | null;
|
||||
seasonNumber: number | null;
|
||||
episodeNumber: number | null;
|
||||
year: number | null;
|
||||
ipAddress: string;
|
||||
geoCity: string | null;
|
||||
geoRegion: string | null;
|
||||
geoCountry: string | null;
|
||||
geoLat: number | null;
|
||||
geoLon: number | null;
|
||||
playerName: string | null;
|
||||
device: string | null;
|
||||
deviceId: string | null;
|
||||
platform: string | null;
|
||||
product: string | null;
|
||||
quality: string | null;
|
||||
startedAt: Date;
|
||||
}
|
||||
|
||||
export interface ViolationWithDetails extends Violation {
|
||||
rule: Pick<Rule, 'id' | 'name' | 'type'>;
|
||||
user: Pick<ServerUser, 'id' | 'username' | 'thumbUrl' | 'serverId'> & { identityName: string | null };
|
||||
server?: Pick<Server, 'id' | 'name' | 'type'>;
|
||||
session?: ViolationSessionInfo;
|
||||
relatedSessions?: ViolationSessionInfo[];
|
||||
userHistory?: {
|
||||
previousIPs: string[];
|
||||
previousDevices: string[];
|
||||
previousLocations: Array<{ city: string | null; country: string | null; ip: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
// Stats types
|
||||
export interface DashboardStats {
|
||||
activeStreams: number;
|
||||
todayPlays: number;
|
||||
watchTimeHours: number;
|
||||
alertsLast24h: number;
|
||||
activeUsersToday: number;
|
||||
}
|
||||
|
||||
export interface PlayStats {
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
serverUserId: string;
|
||||
username: string;
|
||||
thumbUrl: string | null;
|
||||
playCount: number;
|
||||
watchTimeHours: number;
|
||||
}
|
||||
|
||||
export interface LocationUserInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
thumbUrl: string | null;
|
||||
}
|
||||
|
||||
export interface LocationStats {
|
||||
city: string | null;
|
||||
region: string | null; // State/province
|
||||
country: string | null;
|
||||
lat: number;
|
||||
lon: number;
|
||||
count: number;
|
||||
lastActivity?: Date;
|
||||
firstActivity?: Date;
|
||||
// Contextual data - populated based on filters
|
||||
users?: LocationUserInfo[]; // Top users at this location (when not filtering by userId)
|
||||
deviceCount?: number; // Unique devices from this location
|
||||
}
|
||||
|
||||
export interface LocationStatsSummary {
|
||||
totalStreams: number;
|
||||
uniqueLocations: number;
|
||||
topCity: string | null;
|
||||
}
|
||||
|
||||
export interface LocationFilterOptions {
|
||||
users: { id: string; username: string; identityName: string | null }[];
|
||||
servers: { id: string; name: string }[];
|
||||
mediaTypes: ('movie' | 'episode' | 'track')[];
|
||||
}
|
||||
|
||||
export interface LocationStatsResponse {
|
||||
data: LocationStats[];
|
||||
summary: LocationStatsSummary;
|
||||
availableFilters: LocationFilterOptions;
|
||||
}
|
||||
|
||||
export interface LibraryStats {
|
||||
movies: number;
|
||||
shows: number;
|
||||
episodes: number;
|
||||
tracks: number;
|
||||
}
|
||||
|
||||
export interface DayOfWeekStats {
|
||||
day: number; // 0 = Sunday, 6 = Saturday
|
||||
name: string; // 'Sun', 'Mon', etc.
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface HourOfDayStats {
|
||||
hour: number; // 0-23
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface QualityStats {
|
||||
directPlay: number;
|
||||
transcode: number;
|
||||
total: number;
|
||||
directPlayPercent: number;
|
||||
transcodePercent: number;
|
||||
}
|
||||
|
||||
export interface TopUserStats {
|
||||
serverUserId: string;
|
||||
username: string;
|
||||
identityName: string | null;
|
||||
thumbUrl: string | null;
|
||||
serverId: string | null;
|
||||
trustScore: number;
|
||||
playCount: number;
|
||||
watchTimeHours: number;
|
||||
topMediaType: string | null; // "movie", "episode", etc.
|
||||
topContent: string | null; // Most watched show/movie name
|
||||
}
|
||||
|
||||
export interface TopContentStats {
|
||||
title: string;
|
||||
type: string;
|
||||
showTitle: string | null; // For episodes, this is the show name
|
||||
year: number | null;
|
||||
playCount: number;
|
||||
watchTimeHours: number;
|
||||
thumbPath: string | null;
|
||||
serverId: string | null;
|
||||
ratingKey: string | null;
|
||||
}
|
||||
|
||||
export interface PlatformStats {
|
||||
platform: string | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// Server resource statistics (CPU, RAM)
|
||||
// From Plex's undocumented /statistics/resources endpoint
|
||||
export interface ServerResourceDataPoint {
|
||||
/** Unix timestamp */
|
||||
at: number;
|
||||
/** Timespan interval in seconds */
|
||||
timespan: number;
|
||||
/** System-wide CPU utilization percentage */
|
||||
hostCpuUtilization: number;
|
||||
/** Plex process CPU utilization percentage */
|
||||
processCpuUtilization: number;
|
||||
/** System-wide memory utilization percentage */
|
||||
hostMemoryUtilization: number;
|
||||
/** Plex process memory utilization percentage */
|
||||
processMemoryUtilization: number;
|
||||
}
|
||||
|
||||
export interface ServerResourceStats {
|
||||
/** Server ID these stats belong to */
|
||||
serverId: string;
|
||||
/** Data points (newest first based on 'at' timestamp) */
|
||||
data: ServerResourceDataPoint[];
|
||||
/** When this data was fetched */
|
||||
fetchedAt: Date;
|
||||
}
|
||||
|
||||
// Webhook format types
|
||||
export type WebhookFormat = 'json' | 'ntfy' | 'apprise';
|
||||
|
||||
// Unit system for display preferences (stored in settings)
|
||||
export type UnitSystem = 'metric' | 'imperial';
|
||||
|
||||
// Settings types
|
||||
export interface Settings {
|
||||
allowGuestAccess: boolean;
|
||||
// Display preferences
|
||||
unitSystem: UnitSystem;
|
||||
discordWebhookUrl: string | null;
|
||||
customWebhookUrl: string | null;
|
||||
webhookFormat: WebhookFormat | null;
|
||||
ntfyTopic: string | null;
|
||||
// Poller settings
|
||||
pollerEnabled: boolean;
|
||||
pollerIntervalMs: number;
|
||||
// Tautulli integration
|
||||
tautulliUrl: string | null;
|
||||
tautulliApiKey: string | null;
|
||||
// Network/access settings
|
||||
externalUrl: string | null;
|
||||
basePath: string;
|
||||
trustProxy: boolean;
|
||||
// Mobile access
|
||||
mobileEnabled: boolean;
|
||||
// Authentication settings
|
||||
primaryAuthMethod: 'jellyfin' | 'local';
|
||||
}
|
||||
|
||||
// Tautulli import types
|
||||
export interface TautulliImportProgress {
|
||||
status: 'idle' | 'fetching' | 'processing' | 'complete' | 'error';
|
||||
/** Expected total from API (may differ from actual if API count is stale) */
|
||||
totalRecords: number;
|
||||
/** Actual records fetched from API so far */
|
||||
fetchedRecords: number;
|
||||
/** Records processed (looped through) */
|
||||
processedRecords: number;
|
||||
/** New sessions inserted */
|
||||
importedRecords: number;
|
||||
/** Existing sessions updated with new data */
|
||||
updatedRecords: number;
|
||||
/** Total skipped (sum of duplicate + unknownUser + activeSession) */
|
||||
skippedRecords: number;
|
||||
/** Skipped: already exists in DB or duplicate in this import */
|
||||
duplicateRecords: number;
|
||||
/** Skipped: user not found in Tracearr (need to sync server first) */
|
||||
unknownUserRecords: number;
|
||||
/** Skipped: in-progress sessions without reference_id */
|
||||
activeSessionRecords: number;
|
||||
/** Records that failed to process */
|
||||
errorRecords: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TautulliImportResult {
|
||||
success: boolean;
|
||||
imported: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
errors: number;
|
||||
message: string;
|
||||
/** Details about users that were skipped (not found in Tracearr) */
|
||||
skippedUsers?: {
|
||||
tautulliUserId: number;
|
||||
username: string;
|
||||
recordCount: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
// WebSocket event types
|
||||
export interface ServerToClientEvents {
|
||||
'session:started': (session: ActiveSession) => void;
|
||||
'session:stopped': (sessionId: string) => void;
|
||||
'session:updated': (session: ActiveSession) => void;
|
||||
'violation:new': (violation: ViolationWithDetails) => void;
|
||||
'stats:updated': (stats: DashboardStats) => void;
|
||||
'import:progress': (progress: TautulliImportProgress) => void;
|
||||
}
|
||||
|
||||
export interface ClientToServerEvents {
|
||||
'subscribe:sessions': () => void;
|
||||
'unsubscribe:sessions': () => void;
|
||||
}
|
||||
|
||||
// User location aggregation (derived from sessions)
|
||||
export interface UserLocation {
|
||||
city: string | null;
|
||||
region: string | null; // State/province/subdivision
|
||||
country: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
sessionCount: number;
|
||||
lastSeenAt: Date;
|
||||
ipAddresses: string[];
|
||||
}
|
||||
|
||||
// Device location summary (where a device has been used from)
|
||||
export interface DeviceLocation {
|
||||
city: string | null;
|
||||
region: string | null;
|
||||
country: string | null;
|
||||
sessionCount: number;
|
||||
lastSeenAt: Date;
|
||||
}
|
||||
|
||||
// User device aggregation (derived from sessions)
|
||||
export interface UserDevice {
|
||||
deviceId: string | null;
|
||||
playerName: string | null;
|
||||
product: string | null;
|
||||
device: string | null;
|
||||
platform: string | null;
|
||||
sessionCount: number;
|
||||
lastSeenAt: Date;
|
||||
locations: DeviceLocation[]; // Where this device has been used from
|
||||
}
|
||||
|
||||
// API response types
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
statusCode: number;
|
||||
error: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Mobile App Types
|
||||
// ============================================
|
||||
|
||||
// Mobile pairing token (one-time use)
|
||||
export interface MobileToken {
|
||||
id: string;
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
usedAt: Date | null;
|
||||
}
|
||||
|
||||
// Mobile pairing token response (when generating new token)
|
||||
export interface MobilePairTokenResponse {
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
// Mobile session (paired device)
|
||||
export interface MobileSession {
|
||||
id: string;
|
||||
deviceName: string;
|
||||
deviceId: string;
|
||||
platform: 'ios' | 'android';
|
||||
expoPushToken: string | null;
|
||||
lastSeenAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// Mobile config returned to web dashboard
|
||||
export interface MobileConfig {
|
||||
isEnabled: boolean;
|
||||
sessions: MobileSession[];
|
||||
serverName: string;
|
||||
pendingTokens: number; // Count of unexpired, unused tokens
|
||||
maxDevices: number; // Maximum allowed devices (5)
|
||||
}
|
||||
|
||||
// Mobile pairing request (from mobile app)
|
||||
export interface MobilePairRequest {
|
||||
token: string; // Mobile access token from QR/manual entry
|
||||
deviceName: string; // e.g., "iPhone 15 Pro"
|
||||
deviceId: string; // Unique device identifier
|
||||
platform: 'ios' | 'android';
|
||||
}
|
||||
|
||||
// Mobile pairing response
|
||||
export interface MobilePairResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
server: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'plex' | 'jellyfin' | 'emby';
|
||||
};
|
||||
user: {
|
||||
userId: string;
|
||||
username: string;
|
||||
role: 'owner'; // Mobile access is owner-only for v1
|
||||
};
|
||||
}
|
||||
|
||||
// QR code payload (base64 encoded in tracearr://pair?data=<base64>)
|
||||
export interface MobileQRPayload {
|
||||
url: string; // Server URL
|
||||
token: string; // Mobile access token
|
||||
name: string; // Server name
|
||||
}
|
||||
|
||||
// Notification event types
|
||||
export type NotificationEventType =
|
||||
| 'violation_detected'
|
||||
| 'stream_started'
|
||||
| 'stream_stopped'
|
||||
| 'concurrent_streams'
|
||||
| 'new_device'
|
||||
| 'trust_score_changed'
|
||||
| 'server_down'
|
||||
| 'server_up';
|
||||
|
||||
// Notification preferences (per-device settings)
|
||||
export interface NotificationPreferences {
|
||||
id: string;
|
||||
mobileSessionId: string;
|
||||
|
||||
// Master toggle
|
||||
pushEnabled: boolean;
|
||||
|
||||
// Event toggles
|
||||
onViolationDetected: boolean;
|
||||
onStreamStarted: boolean;
|
||||
onStreamStopped: boolean;
|
||||
onConcurrentStreams: boolean;
|
||||
onNewDevice: boolean;
|
||||
onTrustScoreChanged: boolean;
|
||||
onServerDown: boolean;
|
||||
onServerUp: boolean;
|
||||
|
||||
// Violation filtering
|
||||
violationMinSeverity: number; // 1=low, 2=warning, 3=high
|
||||
violationRuleTypes: string[]; // Empty = all rule types
|
||||
|
||||
// Rate limiting
|
||||
maxPerMinute: number;
|
||||
maxPerHour: number;
|
||||
|
||||
// Quiet hours
|
||||
quietHoursEnabled: boolean;
|
||||
quietHoursStart: string | null; // "23:00"
|
||||
quietHoursEnd: string | null; // "08:00"
|
||||
quietHoursTimezone: string;
|
||||
quietHoursOverrideCritical: boolean;
|
||||
|
||||
// Timestamps
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Rate limit status (returned with preferences for UI display)
|
||||
export interface RateLimitStatus {
|
||||
remainingMinute: number;
|
||||
remainingHour: number;
|
||||
resetMinuteIn: number; // seconds until minute window resets
|
||||
resetHourIn: number; // seconds until hour window resets
|
||||
}
|
||||
|
||||
// Extended preferences response including live rate limit status
|
||||
export interface NotificationPreferencesWithStatus extends NotificationPreferences {
|
||||
rateLimitStatus?: RateLimitStatus;
|
||||
}
|
||||
|
||||
// Notification channel types
|
||||
export type NotificationChannel = 'discord' | 'webhook' | 'push' | 'webToast';
|
||||
|
||||
// Notification channel routing configuration (per-event type)
|
||||
export interface NotificationChannelRouting {
|
||||
id: string;
|
||||
eventType: NotificationEventType;
|
||||
discordEnabled: boolean;
|
||||
webhookEnabled: boolean;
|
||||
pushEnabled: boolean;
|
||||
webToastEnabled: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Encrypted push payload (AES-256-GCM with separate authTag per security best practices)
|
||||
export interface EncryptedPushPayload {
|
||||
v: 1; // Version for future-proofing
|
||||
iv: string; // Base64-encoded 12-byte IV
|
||||
salt: string; // Base64-encoded 16-byte PBKDF2 salt
|
||||
ct: string; // Base64-encoded ciphertext (without authTag)
|
||||
tag: string; // Base64-encoded 16-byte authentication tag
|
||||
}
|
||||
|
||||
// Push notification payload structure (before encryption)
|
||||
export interface PushNotificationPayload {
|
||||
type: NotificationEventType;
|
||||
title: string;
|
||||
body: string;
|
||||
data?: Record<string, unknown>;
|
||||
channelId?: string; // Android notification channel
|
||||
badge?: number; // iOS badge count
|
||||
sound?: string | boolean;
|
||||
priority?: 'default' | 'high';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SSE (Server-Sent Events) Types
|
||||
// =============================================================================
|
||||
|
||||
// SSE connection states
|
||||
export type SSEConnectionState =
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'reconnecting'
|
||||
| 'disconnected'
|
||||
| 'fallback';
|
||||
|
||||
// Plex SSE notification container (outer wrapper)
|
||||
export interface PlexSSENotification {
|
||||
NotificationContainer: {
|
||||
type: string;
|
||||
size: number;
|
||||
PlaySessionStateNotification?: PlexPlaySessionNotification[];
|
||||
ActivityNotification?: PlexActivityNotification[];
|
||||
StatusNotification?: PlexStatusNotification[];
|
||||
TranscodeSession?: PlexTranscodeNotification[];
|
||||
};
|
||||
}
|
||||
|
||||
// Play session state notification (start/stop/pause/resume)
|
||||
export interface PlexPlaySessionNotification {
|
||||
sessionKey: string;
|
||||
clientIdentifier: string;
|
||||
guid: string;
|
||||
ratingKey: string;
|
||||
url: string;
|
||||
key: string;
|
||||
viewOffset: number;
|
||||
playQueueItemID: number;
|
||||
state: 'playing' | 'paused' | 'stopped' | 'buffering';
|
||||
}
|
||||
|
||||
// Activity notification (library scans, etc.)
|
||||
export interface PlexActivityNotification {
|
||||
event: string;
|
||||
uuid: string;
|
||||
Activity: {
|
||||
uuid: string;
|
||||
type: string;
|
||||
cancellable: boolean;
|
||||
userID: number;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
progress: number;
|
||||
Context?: {
|
||||
key: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Status notification (server updates, etc.)
|
||||
export interface PlexStatusNotification {
|
||||
title: string;
|
||||
description: string;
|
||||
notificationName: string;
|
||||
}
|
||||
|
||||
// Transcode session notification
|
||||
export interface PlexTranscodeNotification {
|
||||
key: string;
|
||||
throttled: boolean;
|
||||
complete: boolean;
|
||||
progress: number;
|
||||
size: number;
|
||||
speed: number;
|
||||
error: boolean;
|
||||
duration: number;
|
||||
remaining: number;
|
||||
context: string;
|
||||
sourceVideoCodec: string;
|
||||
sourceAudioCodec: string;
|
||||
videoDecision: string;
|
||||
audioDecision: string;
|
||||
subtitleDecision: string;
|
||||
protocol: string;
|
||||
container: string;
|
||||
videoCodec: string;
|
||||
audioCodec: string;
|
||||
audioChannels: number;
|
||||
transcodeHwRequested: boolean;
|
||||
transcodeHwDecoding: string;
|
||||
transcodeHwEncoding: string;
|
||||
transcodeHwDecodingTitle: string;
|
||||
transcodeHwEncodingTitle: string;
|
||||
}
|
||||
|
||||
// SSE connection status for monitoring
|
||||
export interface SSEConnectionStatus {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
state: SSEConnectionState;
|
||||
connectedAt: Date | null;
|
||||
lastEventAt: Date | null;
|
||||
reconnectAttempts: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Termination Log Types
|
||||
// =============================================================================
|
||||
|
||||
// Trigger source for stream terminations
|
||||
export type TerminationTrigger = 'manual' | 'rule';
|
||||
|
||||
// Termination log with joined details for display
|
||||
export interface TerminationLogWithDetails {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
serverId: string;
|
||||
serverUserId: string;
|
||||
trigger: TerminationTrigger;
|
||||
triggeredByUserId: string | null;
|
||||
triggeredByUsername: string | null; // Joined from users table
|
||||
ruleId: string | null;
|
||||
ruleName: string | null; // Joined from rules table
|
||||
violationId: string | null;
|
||||
reason: string | null;
|
||||
success: boolean;
|
||||
errorMessage: string | null;
|
||||
createdAt: Date;
|
||||
// Session info for context
|
||||
mediaTitle: string | null;
|
||||
mediaType: MediaType | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Plex Server Discovery Types
|
||||
// =============================================================================
|
||||
|
||||
// Connection details for a discovered Plex server
|
||||
export interface PlexDiscoveredConnection {
|
||||
uri: string;
|
||||
local: boolean;
|
||||
address: string;
|
||||
port: number;
|
||||
reachable: boolean; // Tested from Tracearr server
|
||||
latencyMs: number | null; // Response time if reachable
|
||||
}
|
||||
|
||||
// Discovered Plex server from plex.tv resources API
|
||||
export interface PlexDiscoveredServer {
|
||||
name: string;
|
||||
platform: string;
|
||||
version: string;
|
||||
clientIdentifier: string; // Unique server identifier
|
||||
recommendedUri: string | null; // Best reachable connection
|
||||
connections: PlexDiscoveredConnection[];
|
||||
}
|
||||
|
||||
// Response from GET /auth/plex/available-servers
|
||||
export interface PlexAvailableServersResponse {
|
||||
servers: PlexDiscoveredServer[];
|
||||
hasPlexToken: boolean; // False if user has no Plex servers connected
|
||||
}
|
||||
10
packages/shared/tsconfig.json
Normal file
10
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
64
packages/test-utils/package.json
Normal file
64
packages/test-utils/package.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@tracearr/test-utils",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./db": {
|
||||
"types": "./dist/db/index.d.ts",
|
||||
"import": "./dist/db/index.js"
|
||||
},
|
||||
"./factories": {
|
||||
"types": "./dist/factories/index.d.ts",
|
||||
"import": "./dist/factories/index.js"
|
||||
},
|
||||
"./mocks": {
|
||||
"types": "./dist/mocks/index.d.ts",
|
||||
"import": "./dist/mocks/index.js"
|
||||
},
|
||||
"./matchers": {
|
||||
"types": "./dist/matchers/index.d.ts",
|
||||
"import": "./dist/matchers/index.js"
|
||||
},
|
||||
"./helpers": {
|
||||
"types": "./dist/helpers/index.d.ts",
|
||||
"import": "./dist/helpers/index.js"
|
||||
},
|
||||
"./vitest.setup": {
|
||||
"types": "./dist/vitest.setup.d.ts",
|
||||
"import": "./dist/vitest.setup.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"clean": "rm -rf dist .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tracearr/shared": "workspace:*",
|
||||
"drizzle-orm": "^0.44.0",
|
||||
"ioredis": "^5.4.2",
|
||||
"ioredis-mock": "^8.9.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"pg": "^8.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/pg": "^8.11.10",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vitest": "^4.0.0"
|
||||
}
|
||||
}
|
||||
17
packages/test-utils/src/db/index.ts
Normal file
17
packages/test-utils/src/db/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Database utilities for integration tests
|
||||
*
|
||||
* @module @tracearr/test-utils/db
|
||||
*/
|
||||
|
||||
export { getTestPool, getTestDb, closeTestPool, executeRawSql } from './pool.js';
|
||||
export { setupTestDb, isTestDbReady, waitForTestDb } from './setup.js';
|
||||
export { resetTestDb, teardownTestDb, truncateTables } from './reset.js';
|
||||
export {
|
||||
seedBasicOwner,
|
||||
seedMultipleUsers,
|
||||
seedUserWithSessions,
|
||||
seedViolationScenario,
|
||||
seedMobilePairing,
|
||||
type SeedResult,
|
||||
} from './seed.js';
|
||||
76
packages/test-utils/src/db/pool.ts
Normal file
76
packages/test-utils/src/db/pool.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Test database connection pool management
|
||||
*
|
||||
* Provides isolated connections for parallel test workers with
|
||||
* schema-per-worker isolation for concurrent integration tests.
|
||||
*/
|
||||
|
||||
import pg from 'pg';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
let testPool: pg.Pool | null = null;
|
||||
let testDb: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
/**
|
||||
* Get or create the test database connection pool
|
||||
*/
|
||||
export function getTestPool(): pg.Pool {
|
||||
if (!testPool) {
|
||||
// Use port 5433 for test database (docker-compose.test.yml) to avoid conflicts with dev
|
||||
const connectionString =
|
||||
process.env.TEST_DATABASE_URL ||
|
||||
process.env.DATABASE_URL ||
|
||||
'postgresql://test:test@localhost:5433/tracearr_test';
|
||||
|
||||
testPool = new Pool({
|
||||
connectionString,
|
||||
max: 5, // Lower for tests to avoid connection exhaustion
|
||||
idleTimeoutMillis: 10000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
testPool.on('error', (err) => {
|
||||
console.error('[Test DB Pool Error]', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
return testPool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Drizzle ORM instance for tests
|
||||
*
|
||||
* Note: Schema must be imported dynamically by the test setup
|
||||
* to avoid circular dependencies with the main app.
|
||||
*/
|
||||
export function getTestDb<T extends Record<string, unknown>>(schema: T) {
|
||||
if (!testDb) {
|
||||
testDb = drizzle(getTestPool(), { schema });
|
||||
}
|
||||
return testDb as ReturnType<typeof drizzle<T>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the test database pool
|
||||
*
|
||||
* Call this in global teardown to release connections.
|
||||
*/
|
||||
export async function closeTestPool(): Promise<void> {
|
||||
if (testPool) {
|
||||
await testPool.end();
|
||||
testPool = null;
|
||||
testDb = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute raw SQL on the test database
|
||||
*
|
||||
* Useful for schema setup, truncation, and other DDL operations.
|
||||
*/
|
||||
export async function executeRawSql(sql: string): Promise<pg.QueryResult> {
|
||||
const pool = getTestPool();
|
||||
return pool.query(sql);
|
||||
}
|
||||
88
packages/test-utils/src/db/reset.ts
Normal file
88
packages/test-utils/src/db/reset.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Test database reset utilities
|
||||
*
|
||||
* Provides fast truncation between test files while preserving schema.
|
||||
* Uses TRUNCATE CASCADE for efficient cleanup.
|
||||
*/
|
||||
|
||||
import { executeRawSql, closeTestPool } from './pool.js';
|
||||
|
||||
/**
|
||||
* Tables to truncate in dependency order (leaf tables first)
|
||||
*
|
||||
* Order matters for CASCADE to work properly:
|
||||
* 1. violations (depends on rules, server_users, sessions)
|
||||
* 2. notification_preferences (depends on mobile_sessions)
|
||||
* 3. notification_channel_routing (standalone)
|
||||
* 4. mobile_sessions (depends on nothing)
|
||||
* 5. mobile_tokens (depends on users)
|
||||
* 6. sessions (depends on servers, server_users)
|
||||
* 7. rules (depends on server_users)
|
||||
* 8. server_users (depends on users, servers)
|
||||
* 9. servers (standalone)
|
||||
* 10. users (standalone)
|
||||
* 11. settings (standalone, single row)
|
||||
*/
|
||||
const TABLES_TO_TRUNCATE = [
|
||||
'violations',
|
||||
'notification_preferences',
|
||||
'notification_channel_routing',
|
||||
'mobile_sessions',
|
||||
'mobile_tokens',
|
||||
'sessions',
|
||||
'rules',
|
||||
'server_users',
|
||||
'servers',
|
||||
'users',
|
||||
// Settings is a single-row config table, reset to defaults instead
|
||||
];
|
||||
|
||||
/**
|
||||
* Reset the test database between test files
|
||||
*
|
||||
* Truncates all tables but preserves schema.
|
||||
* Fast and efficient for integration tests.
|
||||
*
|
||||
* Call this in afterEach() to ensure test isolation.
|
||||
*/
|
||||
export async function resetTestDb(): Promise<void> {
|
||||
try {
|
||||
// Use a single TRUNCATE command with CASCADE for efficiency
|
||||
await executeRawSql(
|
||||
`TRUNCATE TABLE ${TABLES_TO_TRUNCATE.join(', ')} RESTART IDENTITY CASCADE`
|
||||
);
|
||||
|
||||
// Reset settings to defaults (it's a single-row table)
|
||||
await executeRawSql(`
|
||||
DELETE FROM settings WHERE id = 1;
|
||||
INSERT INTO settings (id) VALUES (1);
|
||||
`);
|
||||
} catch (error) {
|
||||
// Table might not exist yet if migrations haven't run
|
||||
if (error instanceof Error && error.message.includes('does not exist')) {
|
||||
console.warn('[Test Reset] Tables do not exist yet, skipping truncation');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full teardown of test database resources
|
||||
*
|
||||
* Call this in global afterAll() to release connections.
|
||||
*/
|
||||
export async function teardownTestDb(): Promise<void> {
|
||||
await closeTestPool();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up specific tables (useful for targeted cleanup)
|
||||
*/
|
||||
export async function truncateTables(tables: string[]): Promise<void> {
|
||||
if (tables.length === 0) return;
|
||||
|
||||
await executeRawSql(
|
||||
`TRUNCATE TABLE ${tables.join(', ')} RESTART IDENTITY CASCADE`
|
||||
);
|
||||
}
|
||||
204
packages/test-utils/src/db/seed.ts
Normal file
204
packages/test-utils/src/db/seed.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Test database seeding utilities
|
||||
*
|
||||
* Pre-built scenarios for common test setups.
|
||||
* Use these to quickly set up test data without repetitive boilerplate.
|
||||
*/
|
||||
|
||||
import { executeRawSql } from './pool.js';
|
||||
|
||||
export interface SeedResult {
|
||||
userId: string;
|
||||
serverId: string;
|
||||
serverUserId: string;
|
||||
ruleId?: string;
|
||||
sessionId?: string;
|
||||
violationId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a basic owner user with a Plex server
|
||||
*
|
||||
* Creates:
|
||||
* - 1 owner user
|
||||
* - 1 Plex server
|
||||
* - 1 server_user linking them
|
||||
* - Default settings row
|
||||
*/
|
||||
export async function seedBasicOwner(): Promise<SeedResult> {
|
||||
// Create owner user
|
||||
const userResult = await executeRawSql(`
|
||||
INSERT INTO users (username, name, role, aggregate_trust_score)
|
||||
VALUES ('testowner', 'Test Owner', 'owner', 100)
|
||||
RETURNING id
|
||||
`);
|
||||
const userId = userResult.rows[0].id as string;
|
||||
|
||||
// Create Plex server
|
||||
const serverResult = await executeRawSql(`
|
||||
INSERT INTO servers (name, type, url, token)
|
||||
VALUES ('Test Plex Server', 'plex', 'http://localhost:32400', 'test-token-encrypted')
|
||||
RETURNING id
|
||||
`);
|
||||
const serverId = serverResult.rows[0].id as string;
|
||||
|
||||
// Create server_user
|
||||
const serverUserResult = await executeRawSql(`
|
||||
INSERT INTO server_users (user_id, server_id, external_id, username, is_server_admin, trust_score)
|
||||
VALUES ('${userId}', '${serverId}', 'plex-user-1', 'testowner', true, 100)
|
||||
RETURNING id
|
||||
`);
|
||||
const serverUserId = serverUserResult.rows[0].id as string;
|
||||
|
||||
// Ensure settings row exists
|
||||
await executeRawSql(`
|
||||
INSERT INTO settings (id) VALUES (1)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`);
|
||||
|
||||
return { userId, serverId, serverUserId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed multiple users with a shared server
|
||||
*
|
||||
* Creates:
|
||||
* - 1 owner user
|
||||
* - N member users
|
||||
* - 1 Plex server
|
||||
* - N+1 server_users
|
||||
*/
|
||||
export async function seedMultipleUsers(
|
||||
memberCount: number = 3
|
||||
): Promise<{
|
||||
owner: SeedResult;
|
||||
members: SeedResult[];
|
||||
}> {
|
||||
const owner = await seedBasicOwner();
|
||||
const members: SeedResult[] = [];
|
||||
|
||||
for (let i = 0; i < memberCount; i++) {
|
||||
// Create member user
|
||||
const userResult = await executeRawSql(`
|
||||
INSERT INTO users (username, name, role, aggregate_trust_score)
|
||||
VALUES ('member${i + 1}', 'Member ${i + 1}', 'member', 100)
|
||||
RETURNING id
|
||||
`);
|
||||
const userId = userResult.rows[0].id as string;
|
||||
|
||||
// Create server_user for member
|
||||
const serverUserResult = await executeRawSql(`
|
||||
INSERT INTO server_users (user_id, server_id, external_id, username, is_server_admin, trust_score)
|
||||
VALUES ('${userId}', '${owner.serverId}', 'plex-user-${i + 2}', 'member${i + 1}', false, 100)
|
||||
RETURNING id
|
||||
`);
|
||||
const serverUserId = serverUserResult.rows[0].id as string;
|
||||
|
||||
members.push({
|
||||
userId,
|
||||
serverId: owner.serverId,
|
||||
serverUserId,
|
||||
});
|
||||
}
|
||||
|
||||
return { owner, members };
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a user with active sessions
|
||||
*
|
||||
* Creates a user with N active (playing) sessions.
|
||||
* Useful for testing concurrent streams detection.
|
||||
*/
|
||||
export async function seedUserWithSessions(
|
||||
sessionCount: number = 2
|
||||
): Promise<SeedResult & { sessionIds: string[] }> {
|
||||
const base = await seedBasicOwner();
|
||||
const sessionIds: string[] = [];
|
||||
|
||||
for (let i = 0; i < sessionCount; i++) {
|
||||
const sessionResult = await executeRawSql(`
|
||||
INSERT INTO sessions (
|
||||
server_id, server_user_id, session_key, state, media_type,
|
||||
media_title, ip_address, geo_city, geo_country, geo_lat, geo_lon,
|
||||
device_id, platform
|
||||
) VALUES (
|
||||
'${base.serverId}', '${base.serverUserId}', 'session-${i + 1}', 'playing', 'movie',
|
||||
'Test Movie ${i + 1}', '192.168.1.${100 + i}', 'Test City', 'US', 40.7128, -74.0060,
|
||||
'device-${i + 1}', 'Plex Web'
|
||||
)
|
||||
RETURNING id
|
||||
`);
|
||||
sessionIds.push(sessionResult.rows[0].id as string);
|
||||
}
|
||||
|
||||
return { ...base, sessionIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a complete rule evaluation scenario
|
||||
*
|
||||
* Creates:
|
||||
* - Owner user with server
|
||||
* - Active rule (concurrent_streams with max 2)
|
||||
* - 3 active sessions (triggers violation)
|
||||
*/
|
||||
export async function seedViolationScenario(): Promise<
|
||||
SeedResult & {
|
||||
ruleId: string;
|
||||
sessionIds: string[];
|
||||
violationId?: string;
|
||||
}
|
||||
> {
|
||||
const base = await seedUserWithSessions(3);
|
||||
|
||||
// Create concurrent streams rule
|
||||
const ruleResult = await executeRawSql(`
|
||||
INSERT INTO rules (name, type, params, is_active)
|
||||
VALUES (
|
||||
'Max 2 Streams',
|
||||
'concurrent_streams',
|
||||
'{"max_streams": 2}'::jsonb,
|
||||
true
|
||||
)
|
||||
RETURNING id
|
||||
`);
|
||||
const ruleId = ruleResult.rows[0].id as string;
|
||||
|
||||
return {
|
||||
...base,
|
||||
ruleId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed mobile pairing scenario
|
||||
*
|
||||
* Creates:
|
||||
* - Owner user
|
||||
* - Valid mobile pairing token
|
||||
*/
|
||||
export async function seedMobilePairing(): Promise<
|
||||
SeedResult & { tokenHash: string }
|
||||
> {
|
||||
const base = await seedBasicOwner();
|
||||
|
||||
// Enable mobile in settings
|
||||
await executeRawSql(`
|
||||
UPDATE settings SET mobile_enabled = true WHERE id = 1
|
||||
`);
|
||||
|
||||
// Create a pairing token (hash of 'test-mobile-token')
|
||||
// In real usage, this would be SHA-256 hash
|
||||
const tokenHash = 'abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab';
|
||||
await executeRawSql(`
|
||||
INSERT INTO mobile_tokens (token_hash, expires_at, created_by)
|
||||
VALUES (
|
||||
'${tokenHash}',
|
||||
NOW() + INTERVAL '15 minutes',
|
||||
'${base.userId}'
|
||||
)
|
||||
`);
|
||||
|
||||
return { ...base, tokenHash };
|
||||
}
|
||||
77
packages/test-utils/src/db/setup.ts
Normal file
77
packages/test-utils/src/db/setup.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Test database setup utilities
|
||||
*
|
||||
* Sets up the test database with schema and TimescaleDB extensions.
|
||||
* Designed for integration tests that need a real database.
|
||||
*/
|
||||
|
||||
import { getTestPool, executeRawSql } from './pool.js';
|
||||
|
||||
let isSetup = false;
|
||||
|
||||
/**
|
||||
* Set up the test database
|
||||
*
|
||||
* - Creates TimescaleDB extension if not exists
|
||||
* - Runs migrations or pushes schema
|
||||
* - Should be called once in global setup (beforeAll)
|
||||
*/
|
||||
export async function setupTestDb(): Promise<void> {
|
||||
if (isSetup) return;
|
||||
|
||||
const pool = getTestPool();
|
||||
|
||||
// Verify connection
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
await client.query('SELECT 1');
|
||||
client.release();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to connect to test database. Ensure TimescaleDB is running. Error: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
}
|
||||
|
||||
// Enable TimescaleDB extension (if available)
|
||||
try {
|
||||
await executeRawSql('CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE');
|
||||
} catch {
|
||||
// TimescaleDB may not be available in all test environments
|
||||
console.warn('[Test Setup] TimescaleDB extension not available, continuing without it');
|
||||
}
|
||||
|
||||
isSetup = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if test database is ready
|
||||
*/
|
||||
export async function isTestDbReady(): Promise<boolean> {
|
||||
try {
|
||||
const pool = getTestPool();
|
||||
const client = await pool.connect();
|
||||
await client.query('SELECT 1');
|
||||
client.release();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for test database to be ready (with retries)
|
||||
*
|
||||
* Useful in CI where database container may still be starting.
|
||||
*/
|
||||
export async function waitForTestDb(
|
||||
maxRetries = 30,
|
||||
retryDelayMs = 1000
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
if (await isTestDbReady()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
||||
}
|
||||
throw new Error(`Test database not ready after ${maxRetries} retries`);
|
||||
}
|
||||
111
packages/test-utils/src/factories/index.ts
Normal file
111
packages/test-utils/src/factories/index.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Test factories for creating database entities
|
||||
*
|
||||
* @module @tracearr/test-utils/factories
|
||||
*/
|
||||
|
||||
// Import reset functions for local use in resetAllFactoryCounters()
|
||||
import { resetUserCounter } from './user.js';
|
||||
import { resetServerCounter } from './server.js';
|
||||
import { resetServerUserCounter } from './serverUser.js';
|
||||
import { resetSessionCounter } from './session.js';
|
||||
import { resetRuleCounter } from './rule.js';
|
||||
import { resetViolationCounter } from './violation.js';
|
||||
|
||||
export {
|
||||
buildUser,
|
||||
createTestUser,
|
||||
createTestOwner,
|
||||
createTestAdmin,
|
||||
createTestMember,
|
||||
createTestUsers,
|
||||
resetUserCounter,
|
||||
type UserData,
|
||||
type CreatedUser,
|
||||
} from './user.js';
|
||||
|
||||
export {
|
||||
buildServer,
|
||||
createTestServer,
|
||||
createTestPlexServer,
|
||||
createTestJellyfinServer,
|
||||
createTestEmbyServer,
|
||||
createTestServers,
|
||||
resetServerCounter,
|
||||
type ServerData,
|
||||
type CreatedServer,
|
||||
type ServerType,
|
||||
} from './server.js';
|
||||
|
||||
export {
|
||||
buildServerUser,
|
||||
createTestServerUser,
|
||||
createTestServerAdmin,
|
||||
resetServerUserCounter,
|
||||
type ServerUserData,
|
||||
type CreatedServerUser,
|
||||
} from './serverUser.js';
|
||||
|
||||
export {
|
||||
buildSession,
|
||||
createTestSession,
|
||||
createActiveSession,
|
||||
createPausedSession,
|
||||
createStoppedSession,
|
||||
createEpisodeSession,
|
||||
createConcurrentSessions,
|
||||
resetSessionCounter,
|
||||
type SessionData,
|
||||
type CreatedSession,
|
||||
type SessionState,
|
||||
type MediaType,
|
||||
} from './session.js';
|
||||
|
||||
export {
|
||||
buildRule,
|
||||
createTestRule,
|
||||
createImpossibleTravelRule,
|
||||
createSimultaneousLocationsRule,
|
||||
createDeviceVelocityRule,
|
||||
createConcurrentStreamsRule,
|
||||
createGeoRestrictionRule,
|
||||
resetRuleCounter,
|
||||
type RuleData,
|
||||
type CreatedRule,
|
||||
type RuleType,
|
||||
type RuleParams,
|
||||
type ImpossibleTravelParams,
|
||||
type SimultaneousLocationsParams,
|
||||
type DeviceVelocityParams,
|
||||
type ConcurrentStreamsParams,
|
||||
type GeoRestrictionParams,
|
||||
} from './rule.js';
|
||||
|
||||
export {
|
||||
buildViolation,
|
||||
createTestViolation,
|
||||
createLowViolation,
|
||||
createWarningViolation,
|
||||
createHighViolation,
|
||||
createAcknowledgedViolation,
|
||||
createImpossibleTravelViolation,
|
||||
createConcurrentStreamsViolation,
|
||||
resetViolationCounter,
|
||||
type ViolationData,
|
||||
type CreatedViolation,
|
||||
type ViolationSeverity,
|
||||
} from './violation.js';
|
||||
|
||||
/**
|
||||
* Reset all factory counters
|
||||
*
|
||||
* Call this in beforeEach() if you need predictable IDs
|
||||
*/
|
||||
export function resetAllFactoryCounters(): void {
|
||||
resetUserCounter();
|
||||
resetServerCounter();
|
||||
resetServerUserCounter();
|
||||
resetSessionCounter();
|
||||
resetRuleCounter();
|
||||
resetViolationCounter();
|
||||
}
|
||||
207
packages/test-utils/src/factories/rule.ts
Normal file
207
packages/test-utils/src/factories/rule.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Rule factory for test data generation
|
||||
*
|
||||
* Creates sharing detection rules with proper typed params.
|
||||
*/
|
||||
|
||||
import { executeRawSql } from '../db/pool.js';
|
||||
|
||||
export type RuleType =
|
||||
| 'impossible_travel'
|
||||
| 'simultaneous_locations'
|
||||
| 'device_velocity'
|
||||
| 'concurrent_streams'
|
||||
| 'geo_restriction';
|
||||
|
||||
export interface ImpossibleTravelParams {
|
||||
max_speed_kmh: number;
|
||||
}
|
||||
|
||||
export interface SimultaneousLocationsParams {
|
||||
min_distance_km: number;
|
||||
}
|
||||
|
||||
export interface DeviceVelocityParams {
|
||||
max_ips: number;
|
||||
window_hours: number;
|
||||
}
|
||||
|
||||
export interface ConcurrentStreamsParams {
|
||||
max_streams: number;
|
||||
}
|
||||
|
||||
export interface GeoRestrictionParams {
|
||||
blocked_countries: string[];
|
||||
}
|
||||
|
||||
export type RuleParams =
|
||||
| ImpossibleTravelParams
|
||||
| SimultaneousLocationsParams
|
||||
| DeviceVelocityParams
|
||||
| ConcurrentStreamsParams
|
||||
| GeoRestrictionParams;
|
||||
|
||||
export interface RuleData {
|
||||
id?: string;
|
||||
name?: string;
|
||||
type: RuleType;
|
||||
params: RuleParams;
|
||||
serverUserId?: string | null;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface CreatedRule {
|
||||
id: string;
|
||||
name: string;
|
||||
type: RuleType;
|
||||
params: RuleParams;
|
||||
serverUserId: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
let ruleCounter = 0;
|
||||
|
||||
/**
|
||||
* Default params for each rule type
|
||||
*/
|
||||
const DEFAULT_PARAMS: Record<RuleType, RuleParams> = {
|
||||
impossible_travel: { max_speed_kmh: 500 },
|
||||
simultaneous_locations: { min_distance_km: 100 },
|
||||
device_velocity: { max_ips: 5, window_hours: 24 },
|
||||
concurrent_streams: { max_streams: 3 },
|
||||
geo_restriction: { blocked_countries: [] },
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate unique rule data with defaults
|
||||
*/
|
||||
export function buildRule(overrides: RuleData): Required<RuleData> {
|
||||
const index = ++ruleCounter;
|
||||
const type = overrides.type;
|
||||
|
||||
return {
|
||||
id: overrides.id ?? crypto.randomUUID(),
|
||||
name: overrides.name ?? `${type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())} Rule ${index}`,
|
||||
type,
|
||||
params: overrides.params ?? DEFAULT_PARAMS[type],
|
||||
serverUserId: overrides.serverUserId ?? null,
|
||||
isActive: overrides.isActive ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a rule in the database
|
||||
*/
|
||||
export async function createTestRule(data: RuleData): Promise<CreatedRule> {
|
||||
const fullData = buildRule(data);
|
||||
|
||||
const result = await executeRawSql(`
|
||||
INSERT INTO rules (id, name, type, params, server_user_id, is_active)
|
||||
VALUES (
|
||||
'${fullData.id}',
|
||||
'${fullData.name}',
|
||||
'${fullData.type}',
|
||||
'${JSON.stringify(fullData.params)}'::jsonb,
|
||||
${fullData.serverUserId ? `'${fullData.serverUserId}'` : 'NULL'},
|
||||
${fullData.isActive}
|
||||
)
|
||||
RETURNING *
|
||||
`);
|
||||
|
||||
return mapRuleRow(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an impossible travel rule
|
||||
*/
|
||||
export async function createImpossibleTravelRule(
|
||||
params: Partial<ImpossibleTravelParams> = {},
|
||||
overrides: Partial<Omit<RuleData, 'type' | 'params'>> = {}
|
||||
): Promise<CreatedRule> {
|
||||
return createTestRule({
|
||||
type: 'impossible_travel',
|
||||
params: { ...DEFAULT_PARAMS.impossible_travel, ...params } as ImpossibleTravelParams,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simultaneous locations rule
|
||||
*/
|
||||
export async function createSimultaneousLocationsRule(
|
||||
params: Partial<SimultaneousLocationsParams> = {},
|
||||
overrides: Partial<Omit<RuleData, 'type' | 'params'>> = {}
|
||||
): Promise<CreatedRule> {
|
||||
return createTestRule({
|
||||
type: 'simultaneous_locations',
|
||||
params: { ...DEFAULT_PARAMS.simultaneous_locations, ...params } as SimultaneousLocationsParams,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a device velocity rule
|
||||
*/
|
||||
export async function createDeviceVelocityRule(
|
||||
params: Partial<DeviceVelocityParams> = {},
|
||||
overrides: Partial<Omit<RuleData, 'type' | 'params'>> = {}
|
||||
): Promise<CreatedRule> {
|
||||
return createTestRule({
|
||||
type: 'device_velocity',
|
||||
params: { ...DEFAULT_PARAMS.device_velocity, ...params } as DeviceVelocityParams,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a concurrent streams rule
|
||||
*/
|
||||
export async function createConcurrentStreamsRule(
|
||||
params: Partial<ConcurrentStreamsParams> = {},
|
||||
overrides: Partial<Omit<RuleData, 'type' | 'params'>> = {}
|
||||
): Promise<CreatedRule> {
|
||||
return createTestRule({
|
||||
type: 'concurrent_streams',
|
||||
params: { ...DEFAULT_PARAMS.concurrent_streams, ...params } as ConcurrentStreamsParams,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a geo restriction rule
|
||||
*/
|
||||
export async function createGeoRestrictionRule(
|
||||
params: Partial<GeoRestrictionParams> = {},
|
||||
overrides: Partial<Omit<RuleData, 'type' | 'params'>> = {}
|
||||
): Promise<CreatedRule> {
|
||||
return createTestRule({
|
||||
type: 'geo_restriction',
|
||||
params: { ...DEFAULT_PARAMS.geo_restriction, ...params } as GeoRestrictionParams,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map database row to typed rule object
|
||||
*/
|
||||
function mapRuleRow(row: Record<string, unknown>): CreatedRule {
|
||||
return {
|
||||
id: row.id as string,
|
||||
name: row.name as string,
|
||||
type: row.type as RuleType,
|
||||
params: row.params as RuleParams,
|
||||
serverUserId: row.server_user_id as string | null,
|
||||
isActive: row.is_active as boolean,
|
||||
createdAt: row.created_at as Date,
|
||||
updatedAt: row.updated_at as Date,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset rule counter
|
||||
*/
|
||||
export function resetRuleCounter(): void {
|
||||
ruleCounter = 0;
|
||||
}
|
||||
133
packages/test-utils/src/factories/server.ts
Normal file
133
packages/test-utils/src/factories/server.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Server factory for test data generation
|
||||
*
|
||||
* Creates server entities with sensible defaults.
|
||||
*/
|
||||
|
||||
import { executeRawSql } from '../db/pool.js';
|
||||
|
||||
export type ServerType = 'plex' | 'jellyfin' | 'emby';
|
||||
|
||||
export interface ServerData {
|
||||
id?: string;
|
||||
name?: string;
|
||||
type?: ServerType;
|
||||
url?: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface CreatedServer extends Required<ServerData> {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
let serverCounter = 0;
|
||||
|
||||
/**
|
||||
* Generate unique server data with defaults
|
||||
*/
|
||||
export function buildServer(overrides: ServerData = {}): Required<ServerData> {
|
||||
const index = ++serverCounter;
|
||||
const type = overrides.type || 'plex';
|
||||
const port = type === 'plex' ? 32400 : type === 'jellyfin' ? 8096 : 8920;
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: `Test ${type.charAt(0).toUpperCase() + type.slice(1)} Server ${index}`,
|
||||
type,
|
||||
url: `http://localhost:${port}`,
|
||||
token: `test-${type}-token-${index}`,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a server in the database
|
||||
*/
|
||||
export async function createTestServer(overrides: ServerData = {}): Promise<CreatedServer> {
|
||||
const data = buildServer(overrides);
|
||||
|
||||
const result = await executeRawSql(`
|
||||
INSERT INTO servers (id, name, type, url, token)
|
||||
VALUES (
|
||||
'${data.id}',
|
||||
'${data.name}',
|
||||
'${data.type}',
|
||||
'${data.url}',
|
||||
'${data.token}'
|
||||
)
|
||||
RETURNING *
|
||||
`);
|
||||
|
||||
return mapServerRow(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Plex server
|
||||
*/
|
||||
export async function createTestPlexServer(overrides: Omit<ServerData, 'type'> = {}): Promise<CreatedServer> {
|
||||
return createTestServer({
|
||||
type: 'plex',
|
||||
url: 'http://localhost:32400',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Jellyfin server
|
||||
*/
|
||||
export async function createTestJellyfinServer(overrides: Omit<ServerData, 'type'> = {}): Promise<CreatedServer> {
|
||||
return createTestServer({
|
||||
type: 'jellyfin',
|
||||
url: 'http://localhost:8096',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Emby server
|
||||
*/
|
||||
export async function createTestEmbyServer(overrides: Omit<ServerData, 'type'> = {}): Promise<CreatedServer> {
|
||||
return createTestServer({
|
||||
type: 'emby',
|
||||
url: 'http://localhost:8920',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple servers
|
||||
*/
|
||||
export async function createTestServers(
|
||||
count: number,
|
||||
overrides: ServerData = {}
|
||||
): Promise<CreatedServer[]> {
|
||||
const servers: CreatedServer[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
servers.push(await createTestServer(overrides));
|
||||
}
|
||||
return servers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map database row to typed server object
|
||||
*/
|
||||
function mapServerRow(row: Record<string, unknown>): CreatedServer {
|
||||
return {
|
||||
id: row.id as string,
|
||||
name: row.name as string,
|
||||
type: row.type as ServerType,
|
||||
url: row.url as string,
|
||||
token: row.token as string,
|
||||
createdAt: row.created_at as Date,
|
||||
updatedAt: row.updated_at as Date,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset server counter
|
||||
*/
|
||||
export function resetServerCounter(): void {
|
||||
serverCounter = 0;
|
||||
}
|
||||
114
packages/test-utils/src/factories/serverUser.ts
Normal file
114
packages/test-utils/src/factories/serverUser.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* ServerUser factory for test data generation
|
||||
*
|
||||
* Creates server_users that link users to specific media servers.
|
||||
*/
|
||||
|
||||
import { executeRawSql } from '../db/pool.js';
|
||||
|
||||
export interface ServerUserData {
|
||||
id?: string;
|
||||
userId: string;
|
||||
serverId: string;
|
||||
externalId?: string;
|
||||
username?: string;
|
||||
email?: string | null;
|
||||
thumbUrl?: string | null;
|
||||
isServerAdmin?: boolean;
|
||||
trustScore?: number;
|
||||
sessionCount?: number;
|
||||
}
|
||||
|
||||
export interface CreatedServerUser extends Required<Omit<ServerUserData, 'email' | 'thumbUrl'>> {
|
||||
id: string;
|
||||
email: string | null;
|
||||
thumbUrl: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
let serverUserCounter = 0;
|
||||
|
||||
/**
|
||||
* Generate unique server user data with defaults
|
||||
*/
|
||||
export function buildServerUser(overrides: ServerUserData): Required<ServerUserData> {
|
||||
const index = ++serverUserCounter;
|
||||
return {
|
||||
id: overrides.id ?? crypto.randomUUID(),
|
||||
userId: overrides.userId,
|
||||
serverId: overrides.serverId,
|
||||
externalId: overrides.externalId ?? `external-user-${index}`,
|
||||
username: overrides.username ?? `serveruser${index}`,
|
||||
email: overrides.email ?? null,
|
||||
thumbUrl: overrides.thumbUrl ?? null,
|
||||
isServerAdmin: overrides.isServerAdmin ?? false,
|
||||
trustScore: overrides.trustScore ?? 100,
|
||||
sessionCount: overrides.sessionCount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a server user in the database
|
||||
*/
|
||||
export async function createTestServerUser(data: ServerUserData): Promise<CreatedServerUser> {
|
||||
const fullData = buildServerUser(data);
|
||||
|
||||
const result = await executeRawSql(`
|
||||
INSERT INTO server_users (
|
||||
id, user_id, server_id, external_id, username, email,
|
||||
thumb_url, is_server_admin, trust_score, session_count
|
||||
) VALUES (
|
||||
'${fullData.id}',
|
||||
'${fullData.userId}',
|
||||
'${fullData.serverId}',
|
||||
'${fullData.externalId}',
|
||||
'${fullData.username}',
|
||||
${fullData.email ? `'${fullData.email}'` : 'NULL'},
|
||||
${fullData.thumbUrl ? `'${fullData.thumbUrl}'` : 'NULL'},
|
||||
${fullData.isServerAdmin},
|
||||
${fullData.trustScore},
|
||||
${fullData.sessionCount}
|
||||
)
|
||||
RETURNING *
|
||||
`);
|
||||
|
||||
return mapServerUserRow(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a server admin user
|
||||
*/
|
||||
export async function createTestServerAdmin(data: ServerUserData): Promise<CreatedServerUser> {
|
||||
return createTestServerUser({
|
||||
...data,
|
||||
isServerAdmin: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map database row to typed server user object
|
||||
*/
|
||||
function mapServerUserRow(row: Record<string, unknown>): CreatedServerUser {
|
||||
return {
|
||||
id: row.id as string,
|
||||
userId: row.user_id as string,
|
||||
serverId: row.server_id as string,
|
||||
externalId: row.external_id as string,
|
||||
username: row.username as string,
|
||||
email: row.email as string | null,
|
||||
thumbUrl: row.thumb_url as string | null,
|
||||
isServerAdmin: row.is_server_admin as boolean,
|
||||
trustScore: row.trust_score as number,
|
||||
sessionCount: row.session_count as number,
|
||||
createdAt: row.created_at as Date,
|
||||
updatedAt: row.updated_at as Date,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset server user counter
|
||||
*/
|
||||
export function resetServerUserCounter(): void {
|
||||
serverUserCounter = 0;
|
||||
}
|
||||
268
packages/test-utils/src/factories/session.ts
Normal file
268
packages/test-utils/src/factories/session.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Session factory for test data generation
|
||||
*
|
||||
* Creates session entities for stream tracking.
|
||||
*/
|
||||
|
||||
import { executeRawSql } from '../db/pool.js';
|
||||
|
||||
export type SessionState = 'playing' | 'paused' | 'stopped';
|
||||
export type MediaType = 'movie' | 'episode' | 'track';
|
||||
|
||||
export interface SessionData {
|
||||
id?: string;
|
||||
serverId: string;
|
||||
serverUserId: string;
|
||||
sessionKey?: string;
|
||||
state?: SessionState;
|
||||
mediaType?: MediaType;
|
||||
mediaTitle?: string;
|
||||
grandparentTitle?: string | null;
|
||||
seasonNumber?: number | null;
|
||||
episodeNumber?: number | null;
|
||||
year?: number | null;
|
||||
thumbPath?: string | null;
|
||||
ratingKey?: string | null;
|
||||
externalSessionId?: string | null;
|
||||
startedAt?: Date;
|
||||
stoppedAt?: Date | null;
|
||||
durationMs?: number | null;
|
||||
totalDurationMs?: number | null;
|
||||
progressMs?: number | null;
|
||||
lastPausedAt?: Date | null;
|
||||
pausedDurationMs?: number;
|
||||
referenceId?: string | null;
|
||||
watched?: boolean;
|
||||
ipAddress?: string;
|
||||
geoCity?: string | null;
|
||||
geoRegion?: string | null;
|
||||
geoCountry?: string | null;
|
||||
geoLat?: number | null;
|
||||
geoLon?: number | null;
|
||||
playerName?: string | null;
|
||||
deviceId?: string | null;
|
||||
product?: string | null;
|
||||
device?: string | null;
|
||||
platform?: string | null;
|
||||
quality?: string | null;
|
||||
isTranscode?: boolean;
|
||||
bitrate?: number | null;
|
||||
}
|
||||
|
||||
export interface CreatedSession {
|
||||
id: string;
|
||||
serverId: string;
|
||||
serverUserId: string;
|
||||
sessionKey: string;
|
||||
state: SessionState;
|
||||
mediaType: MediaType;
|
||||
mediaTitle: string;
|
||||
ipAddress: string;
|
||||
startedAt: Date;
|
||||
// Additional fields omitted for brevity
|
||||
}
|
||||
|
||||
let sessionCounter = 0;
|
||||
|
||||
/**
|
||||
* Generate unique session data with defaults
|
||||
*/
|
||||
export function buildSession(overrides: SessionData): Required<SessionData> {
|
||||
const index = ++sessionCounter;
|
||||
const now = new Date();
|
||||
|
||||
return {
|
||||
id: overrides.id ?? crypto.randomUUID(),
|
||||
serverId: overrides.serverId,
|
||||
serverUserId: overrides.serverUserId,
|
||||
sessionKey: overrides.sessionKey ?? `session-${index}`,
|
||||
state: overrides.state ?? 'playing',
|
||||
mediaType: overrides.mediaType ?? 'movie',
|
||||
mediaTitle: overrides.mediaTitle ?? `Test Movie ${index}`,
|
||||
grandparentTitle: overrides.grandparentTitle ?? null,
|
||||
seasonNumber: overrides.seasonNumber ?? null,
|
||||
episodeNumber: overrides.episodeNumber ?? null,
|
||||
year: overrides.year ?? 2024,
|
||||
thumbPath: overrides.thumbPath ?? null,
|
||||
ratingKey: overrides.ratingKey ?? `ratingkey-${index}`,
|
||||
externalSessionId: overrides.externalSessionId ?? `ext-session-${index}`,
|
||||
startedAt: overrides.startedAt ?? now,
|
||||
stoppedAt: overrides.stoppedAt ?? null,
|
||||
durationMs: overrides.durationMs ?? null,
|
||||
totalDurationMs: overrides.totalDurationMs ?? 7200000, // 2 hours
|
||||
progressMs: overrides.progressMs ?? 0,
|
||||
lastPausedAt: overrides.lastPausedAt ?? null,
|
||||
pausedDurationMs: overrides.pausedDurationMs ?? 0,
|
||||
referenceId: overrides.referenceId ?? null,
|
||||
watched: overrides.watched ?? false,
|
||||
ipAddress: overrides.ipAddress ?? `192.168.1.${100 + (index % 155)}`,
|
||||
geoCity: overrides.geoCity ?? 'New York',
|
||||
geoRegion: overrides.geoRegion ?? 'NY',
|
||||
geoCountry: overrides.geoCountry ?? 'US',
|
||||
geoLat: overrides.geoLat ?? 40.7128,
|
||||
geoLon: overrides.geoLon ?? -74.006,
|
||||
playerName: overrides.playerName ?? `Player ${index}`,
|
||||
deviceId: overrides.deviceId ?? `device-${index}`,
|
||||
product: overrides.product ?? 'Plex Web',
|
||||
device: overrides.device ?? 'Chrome',
|
||||
platform: overrides.platform ?? 'Web',
|
||||
quality: overrides.quality ?? '1080p',
|
||||
isTranscode: overrides.isTranscode ?? false,
|
||||
bitrate: overrides.bitrate ?? 20000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session in the database
|
||||
*/
|
||||
export async function createTestSession(data: SessionData): Promise<CreatedSession> {
|
||||
const fullData = buildSession(data);
|
||||
|
||||
const result = await executeRawSql(`
|
||||
INSERT INTO sessions (
|
||||
id, server_id, server_user_id, session_key, state, media_type,
|
||||
media_title, grandparent_title, season_number, episode_number,
|
||||
year, thumb_path, rating_key, external_session_id,
|
||||
started_at, stopped_at, duration_ms, total_duration_ms, progress_ms,
|
||||
last_paused_at, paused_duration_ms, reference_id, watched,
|
||||
ip_address, geo_city, geo_region, geo_country, geo_lat, geo_lon,
|
||||
player_name, device_id, product, device, platform,
|
||||
quality, is_transcode, bitrate
|
||||
) VALUES (
|
||||
'${fullData.id}',
|
||||
'${fullData.serverId}',
|
||||
'${fullData.serverUserId}',
|
||||
'${fullData.sessionKey}',
|
||||
'${fullData.state}',
|
||||
'${fullData.mediaType}',
|
||||
'${fullData.mediaTitle}',
|
||||
${fullData.grandparentTitle ? `'${fullData.grandparentTitle}'` : 'NULL'},
|
||||
${fullData.seasonNumber ?? 'NULL'},
|
||||
${fullData.episodeNumber ?? 'NULL'},
|
||||
${fullData.year ?? 'NULL'},
|
||||
${fullData.thumbPath ? `'${fullData.thumbPath}'` : 'NULL'},
|
||||
${fullData.ratingKey ? `'${fullData.ratingKey}'` : 'NULL'},
|
||||
${fullData.externalSessionId ? `'${fullData.externalSessionId}'` : 'NULL'},
|
||||
'${fullData.startedAt.toISOString()}',
|
||||
${fullData.stoppedAt ? `'${fullData.stoppedAt.toISOString()}'` : 'NULL'},
|
||||
${fullData.durationMs ?? 'NULL'},
|
||||
${fullData.totalDurationMs ?? 'NULL'},
|
||||
${fullData.progressMs ?? 'NULL'},
|
||||
${fullData.lastPausedAt ? `'${fullData.lastPausedAt.toISOString()}'` : 'NULL'},
|
||||
${fullData.pausedDurationMs},
|
||||
${fullData.referenceId ? `'${fullData.referenceId}'` : 'NULL'},
|
||||
${fullData.watched},
|
||||
'${fullData.ipAddress}',
|
||||
${fullData.geoCity ? `'${fullData.geoCity}'` : 'NULL'},
|
||||
${fullData.geoRegion ? `'${fullData.geoRegion}'` : 'NULL'},
|
||||
${fullData.geoCountry ? `'${fullData.geoCountry}'` : 'NULL'},
|
||||
${fullData.geoLat ?? 'NULL'},
|
||||
${fullData.geoLon ?? 'NULL'},
|
||||
${fullData.playerName ? `'${fullData.playerName}'` : 'NULL'},
|
||||
${fullData.deviceId ? `'${fullData.deviceId}'` : 'NULL'},
|
||||
${fullData.product ? `'${fullData.product}'` : 'NULL'},
|
||||
${fullData.device ? `'${fullData.device}'` : 'NULL'},
|
||||
${fullData.platform ? `'${fullData.platform}'` : 'NULL'},
|
||||
${fullData.quality ? `'${fullData.quality}'` : 'NULL'},
|
||||
${fullData.isTranscode},
|
||||
${fullData.bitrate ?? 'NULL'}
|
||||
)
|
||||
RETURNING *
|
||||
`);
|
||||
|
||||
return mapSessionRow(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an active (playing) session
|
||||
*/
|
||||
export async function createActiveSession(data: SessionData): Promise<CreatedSession> {
|
||||
return createTestSession({
|
||||
...data,
|
||||
state: 'playing',
|
||||
stoppedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a paused session
|
||||
*/
|
||||
export async function createPausedSession(data: SessionData): Promise<CreatedSession> {
|
||||
return createTestSession({
|
||||
...data,
|
||||
state: 'paused',
|
||||
lastPausedAt: data.lastPausedAt ?? new Date(),
|
||||
stoppedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a stopped session
|
||||
*/
|
||||
export async function createStoppedSession(data: SessionData): Promise<CreatedSession> {
|
||||
const now = new Date();
|
||||
return createTestSession({
|
||||
...data,
|
||||
state: 'stopped',
|
||||
stoppedAt: data.stoppedAt ?? now,
|
||||
durationMs: data.durationMs ?? 3600000, // 1 hour watched
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an episode session
|
||||
*/
|
||||
export async function createEpisodeSession(data: SessionData): Promise<CreatedSession> {
|
||||
return createTestSession({
|
||||
...data,
|
||||
mediaType: 'episode',
|
||||
grandparentTitle: data.grandparentTitle ?? 'Test TV Show',
|
||||
seasonNumber: data.seasonNumber ?? 1,
|
||||
episodeNumber: data.episodeNumber ?? 1,
|
||||
mediaTitle: data.mediaTitle ?? 'Pilot',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple sessions for testing concurrent streams
|
||||
*/
|
||||
export async function createConcurrentSessions(
|
||||
count: number,
|
||||
data: SessionData
|
||||
): Promise<CreatedSession[]> {
|
||||
const sessions: CreatedSession[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
sessions.push(
|
||||
await createActiveSession({
|
||||
...data,
|
||||
deviceId: `device-${i + 1}`,
|
||||
ipAddress: `192.168.1.${100 + i}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map database row to typed session object
|
||||
*/
|
||||
function mapSessionRow(row: Record<string, unknown>): CreatedSession {
|
||||
return {
|
||||
id: row.id as string,
|
||||
serverId: row.server_id as string,
|
||||
serverUserId: row.server_user_id as string,
|
||||
sessionKey: row.session_key as string,
|
||||
state: row.state as SessionState,
|
||||
mediaType: row.media_type as MediaType,
|
||||
mediaTitle: row.media_title as string,
|
||||
ipAddress: row.ip_address as string,
|
||||
startedAt: row.started_at as Date,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset session counter
|
||||
*/
|
||||
export function resetSessionCounter(): void {
|
||||
sessionCounter = 0;
|
||||
}
|
||||
144
packages/test-utils/src/factories/user.ts
Normal file
144
packages/test-utils/src/factories/user.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* User factory for test data generation
|
||||
*
|
||||
* Creates user entities with sensible defaults that can be overridden.
|
||||
*/
|
||||
|
||||
import { executeRawSql } from '../db/pool.js';
|
||||
|
||||
export interface UserData {
|
||||
id?: string;
|
||||
username?: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
thumbnail?: string | null;
|
||||
passwordHash?: string | null;
|
||||
plexAccountId?: string | null;
|
||||
role?: 'owner' | 'admin' | 'viewer' | 'member' | 'disabled' | 'pending';
|
||||
aggregateTrustScore?: number;
|
||||
totalViolations?: number;
|
||||
}
|
||||
|
||||
export interface CreatedUser extends Required<Omit<UserData, 'passwordHash' | 'plexAccountId' | 'thumbnail'>> {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
let userCounter = 0;
|
||||
|
||||
/**
|
||||
* Generate unique user data with defaults
|
||||
*/
|
||||
export function buildUser(overrides: UserData = {}): Required<UserData> {
|
||||
const index = ++userCounter;
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
username: `testuser${index}`,
|
||||
name: `Test User ${index}`,
|
||||
email: `testuser${index}@example.com`,
|
||||
thumbnail: null,
|
||||
passwordHash: null,
|
||||
plexAccountId: null,
|
||||
role: 'member',
|
||||
aggregateTrustScore: 100,
|
||||
totalViolations: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a user in the database
|
||||
*/
|
||||
export async function createTestUser(overrides: UserData = {}): Promise<CreatedUser> {
|
||||
const data = buildUser(overrides);
|
||||
|
||||
const result = await executeRawSql(`
|
||||
INSERT INTO users (
|
||||
id, username, name, email, thumbnail, password_hash, plex_account_id,
|
||||
role, aggregate_trust_score, total_violations
|
||||
) VALUES (
|
||||
'${data.id}',
|
||||
'${data.username}',
|
||||
${data.name ? `'${data.name}'` : 'NULL'},
|
||||
${data.email ? `'${data.email}'` : 'NULL'},
|
||||
${data.thumbnail ? `'${data.thumbnail}'` : 'NULL'},
|
||||
${data.passwordHash ? `'${data.passwordHash}'` : 'NULL'},
|
||||
${data.plexAccountId ? `'${data.plexAccountId}'` : 'NULL'},
|
||||
'${data.role}',
|
||||
${data.aggregateTrustScore},
|
||||
${data.totalViolations}
|
||||
)
|
||||
RETURNING *
|
||||
`);
|
||||
|
||||
return mapUserRow(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an owner user (with login access)
|
||||
*/
|
||||
export async function createTestOwner(overrides: UserData = {}): Promise<CreatedUser> {
|
||||
return createTestUser({
|
||||
role: 'owner',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an admin user
|
||||
*/
|
||||
export async function createTestAdmin(overrides: UserData = {}): Promise<CreatedUser> {
|
||||
return createTestUser({
|
||||
role: 'admin',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a member user (no login access)
|
||||
*/
|
||||
export async function createTestMember(overrides: UserData = {}): Promise<CreatedUser> {
|
||||
return createTestUser({
|
||||
role: 'member',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple users
|
||||
*/
|
||||
export async function createTestUsers(
|
||||
count: number,
|
||||
overrides: UserData = {}
|
||||
): Promise<CreatedUser[]> {
|
||||
const users: CreatedUser[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
users.push(await createTestUser(overrides));
|
||||
}
|
||||
return users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map database row to typed user object
|
||||
*/
|
||||
function mapUserRow(row: Record<string, unknown>): CreatedUser {
|
||||
return {
|
||||
id: row.id as string,
|
||||
username: row.username as string,
|
||||
name: row.name as string | null,
|
||||
email: row.email as string | null,
|
||||
role: row.role as 'owner' | 'admin' | 'viewer' | 'member' | 'disabled' | 'pending',
|
||||
aggregateTrustScore: row.aggregate_trust_score as number,
|
||||
totalViolations: row.total_violations as number,
|
||||
createdAt: row.created_at as Date,
|
||||
updatedAt: row.updated_at as Date,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset user counter (call in beforeEach if needed)
|
||||
*/
|
||||
export function resetUserCounter(): void {
|
||||
userCounter = 0;
|
||||
}
|
||||
171
packages/test-utils/src/factories/violation.ts
Normal file
171
packages/test-utils/src/factories/violation.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Violation factory for test data generation
|
||||
*
|
||||
* Creates violation records when rules are triggered.
|
||||
*/
|
||||
|
||||
import { executeRawSql } from '../db/pool.js';
|
||||
|
||||
export type ViolationSeverity = 'low' | 'warning' | 'high';
|
||||
|
||||
export interface ViolationData {
|
||||
id?: string;
|
||||
ruleId: string;
|
||||
serverUserId: string;
|
||||
sessionId: string;
|
||||
severity?: ViolationSeverity;
|
||||
data?: Record<string, unknown>;
|
||||
acknowledgedAt?: Date | null;
|
||||
}
|
||||
|
||||
export interface CreatedViolation {
|
||||
id: string;
|
||||
ruleId: string;
|
||||
serverUserId: string;
|
||||
sessionId: string;
|
||||
severity: ViolationSeverity;
|
||||
data: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
acknowledgedAt: Date | null;
|
||||
}
|
||||
|
||||
let violationCounter = 0;
|
||||
|
||||
/**
|
||||
* Generate unique violation data with defaults
|
||||
*/
|
||||
export function buildViolation(overrides: ViolationData): Required<ViolationData> {
|
||||
violationCounter++;
|
||||
// Counter used for predictable test ordering when resetViolationCounter() is called
|
||||
void violationCounter;
|
||||
|
||||
return {
|
||||
id: overrides.id ?? crypto.randomUUID(),
|
||||
ruleId: overrides.ruleId,
|
||||
serverUserId: overrides.serverUserId,
|
||||
sessionId: overrides.sessionId,
|
||||
severity: overrides.severity ?? 'warning',
|
||||
data: overrides.data ?? {},
|
||||
acknowledgedAt: overrides.acknowledgedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a violation in the database
|
||||
*/
|
||||
export async function createTestViolation(data: ViolationData): Promise<CreatedViolation> {
|
||||
const fullData = buildViolation(data);
|
||||
|
||||
const result = await executeRawSql(`
|
||||
INSERT INTO violations (id, rule_id, server_user_id, session_id, severity, data, acknowledged_at)
|
||||
VALUES (
|
||||
'${fullData.id}',
|
||||
'${fullData.ruleId}',
|
||||
'${fullData.serverUserId}',
|
||||
'${fullData.sessionId}',
|
||||
'${fullData.severity}',
|
||||
'${JSON.stringify(fullData.data)}'::jsonb,
|
||||
${fullData.acknowledgedAt ? `'${fullData.acknowledgedAt.toISOString()}'` : 'NULL'}
|
||||
)
|
||||
RETURNING *
|
||||
`);
|
||||
|
||||
return mapViolationRow(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a low severity violation
|
||||
*/
|
||||
export async function createLowViolation(data: ViolationData): Promise<CreatedViolation> {
|
||||
return createTestViolation({
|
||||
severity: 'low',
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a warning severity violation
|
||||
*/
|
||||
export async function createWarningViolation(data: ViolationData): Promise<CreatedViolation> {
|
||||
return createTestViolation({
|
||||
severity: 'warning',
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a high severity violation
|
||||
*/
|
||||
export async function createHighViolation(data: ViolationData): Promise<CreatedViolation> {
|
||||
return createTestViolation({
|
||||
severity: 'high',
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an acknowledged violation
|
||||
*/
|
||||
export async function createAcknowledgedViolation(data: ViolationData): Promise<CreatedViolation> {
|
||||
return createTestViolation({
|
||||
acknowledgedAt: new Date(),
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create violation with impossible travel data
|
||||
*/
|
||||
export async function createImpossibleTravelViolation(
|
||||
data: ViolationData,
|
||||
details: { from_city: string; to_city: string; speed_kmh: number; time_diff_minutes: number }
|
||||
): Promise<CreatedViolation> {
|
||||
return createTestViolation({
|
||||
...data,
|
||||
severity: 'high',
|
||||
data: {
|
||||
type: 'impossible_travel',
|
||||
...details,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create violation with concurrent streams data
|
||||
*/
|
||||
export async function createConcurrentStreamsViolation(
|
||||
data: ViolationData,
|
||||
details: { stream_count: number; max_allowed: number; devices: string[] }
|
||||
): Promise<CreatedViolation> {
|
||||
return createTestViolation({
|
||||
...data,
|
||||
severity: 'warning',
|
||||
data: {
|
||||
type: 'concurrent_streams',
|
||||
...details,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map database row to typed violation object
|
||||
*/
|
||||
function mapViolationRow(row: Record<string, unknown>): CreatedViolation {
|
||||
return {
|
||||
id: row.id as string,
|
||||
ruleId: row.rule_id as string,
|
||||
serverUserId: row.server_user_id as string,
|
||||
sessionId: row.session_id as string,
|
||||
severity: row.severity as ViolationSeverity,
|
||||
data: row.data as Record<string, unknown>,
|
||||
createdAt: row.created_at as Date,
|
||||
acknowledgedAt: row.acknowledged_at as Date | null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset violation counter
|
||||
*/
|
||||
export function resetViolationCounter(): void {
|
||||
violationCounter = 0;
|
||||
}
|
||||
184
packages/test-utils/src/helpers/auth.ts
Normal file
184
packages/test-utils/src/helpers/auth.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Authentication Helpers for Testing
|
||||
*
|
||||
* Utilities for generating test JWTs and auth tokens.
|
||||
*/
|
||||
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const TEST_JWT_SECRET = 'test-jwt-secret-for-testing-only';
|
||||
|
||||
export interface TestTokenPayload {
|
||||
sub: string;
|
||||
role: 'owner' | 'admin' | 'member';
|
||||
email?: string;
|
||||
name?: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
export interface GenerateTokenOptions {
|
||||
userId: string;
|
||||
role?: 'owner' | 'admin' | 'member';
|
||||
email?: string;
|
||||
name?: string;
|
||||
expiresIn?: string | number;
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a test JWT token
|
||||
*/
|
||||
export function generateTestToken(options: GenerateTokenOptions): string {
|
||||
const { userId, role = 'owner', email, name, expiresIn = '1h', secret = TEST_JWT_SECRET } = options;
|
||||
|
||||
const payload: TestTokenPayload = {
|
||||
sub: userId,
|
||||
role,
|
||||
};
|
||||
|
||||
if (email) payload.email = email;
|
||||
if (name) payload.name = name;
|
||||
|
||||
return jwt.sign(payload, secret, { expiresIn: expiresIn as jwt.SignOptions['expiresIn'] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an expired token for testing auth rejection
|
||||
*/
|
||||
export function generateExpiredToken(options: Omit<GenerateTokenOptions, 'expiresIn'>): string {
|
||||
const { userId, role = 'owner', email, name, secret = TEST_JWT_SECRET } = options;
|
||||
|
||||
const payload: TestTokenPayload = {
|
||||
sub: userId,
|
||||
role,
|
||||
iat: Math.floor(Date.now() / 1000) - 7200, // 2 hours ago
|
||||
exp: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
|
||||
};
|
||||
|
||||
if (email) payload.email = email;
|
||||
if (name) payload.name = name;
|
||||
|
||||
// Use direct sign to bypass expiration check
|
||||
return jwt.sign(payload, secret, { noTimestamp: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a token with invalid signature
|
||||
*/
|
||||
export function generateInvalidToken(options: GenerateTokenOptions): string {
|
||||
const token = generateTestToken(options);
|
||||
// Corrupt the signature by replacing last character
|
||||
return token.slice(0, -1) + (token.endsWith('a') ? 'b' : 'a');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a token without verification (for test assertions)
|
||||
*/
|
||||
export function decodeToken(token: string): TestTokenPayload | null {
|
||||
try {
|
||||
const decoded = jwt.decode(token) as TestTokenPayload;
|
||||
return decoded;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a token with test secret
|
||||
*/
|
||||
export function verifyTestToken(token: string, secret = TEST_JWT_SECRET): TestTokenPayload | null {
|
||||
try {
|
||||
return jwt.verify(token, secret) as TestTokenPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an owner token (convenience helper)
|
||||
*/
|
||||
export function generateOwnerToken(userId: string, name = 'Test Owner'): string {
|
||||
return generateTestToken({ userId, role: 'owner', name });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an admin token (convenience helper)
|
||||
*/
|
||||
export function generateAdminToken(userId: string, name = 'Test Admin'): string {
|
||||
return generateTestToken({ userId, role: 'admin', name });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a member token (convenience helper)
|
||||
*/
|
||||
export function generateMemberToken(userId: string, name = 'Test Member'): string {
|
||||
return generateTestToken({ userId, role: 'member', name });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create auth headers for test requests
|
||||
*/
|
||||
export function authHeaders(token: string): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create auth headers with owner token
|
||||
*/
|
||||
export function ownerAuthHeaders(userId: string): Record<string, string> {
|
||||
return authHeaders(generateOwnerToken(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create auth headers with admin token
|
||||
*/
|
||||
export function adminAuthHeaders(userId: string): Record<string, string> {
|
||||
return authHeaders(generateAdminToken(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create auth headers with member token
|
||||
*/
|
||||
export function memberAuthHeaders(userId: string): Record<string, string> {
|
||||
return authHeaders(generateMemberToken(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the test JWT secret (for configuring test server)
|
||||
*/
|
||||
export function getTestJwtSecret(): string {
|
||||
return TEST_JWT_SECRET;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a mobile pairing token
|
||||
*/
|
||||
export function generateMobilePairingToken(length = 32): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||
let result = 'trr_mob_';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a QR code pairing payload
|
||||
*/
|
||||
export function generateQRPairingPayload(options: {
|
||||
token: string;
|
||||
serverUrl: string;
|
||||
userId: string;
|
||||
expiresAt?: Date;
|
||||
}): string {
|
||||
const payload = {
|
||||
token: options.token,
|
||||
serverUrl: options.serverUrl,
|
||||
userId: options.userId,
|
||||
expiresAt: (options.expiresAt ?? new Date(Date.now() + 5 * 60 * 1000)).toISOString(),
|
||||
};
|
||||
return `tracearr://pair?data=${Buffer.from(JSON.stringify(payload)).toString('base64url')}`;
|
||||
}
|
||||
62
packages/test-utils/src/helpers/index.ts
Normal file
62
packages/test-utils/src/helpers/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Test helper utilities
|
||||
*
|
||||
* @module @tracearr/test-utils/helpers
|
||||
*/
|
||||
|
||||
// Authentication helpers
|
||||
export {
|
||||
generateTestToken,
|
||||
generateExpiredToken,
|
||||
generateInvalidToken,
|
||||
decodeToken,
|
||||
verifyTestToken,
|
||||
generateOwnerToken,
|
||||
generateAdminToken,
|
||||
generateMemberToken,
|
||||
authHeaders,
|
||||
ownerAuthHeaders,
|
||||
adminAuthHeaders,
|
||||
memberAuthHeaders,
|
||||
getTestJwtSecret,
|
||||
generateMobilePairingToken,
|
||||
generateQRPairingPayload,
|
||||
type TestTokenPayload,
|
||||
type GenerateTokenOptions,
|
||||
} from './auth.js';
|
||||
|
||||
// Time manipulation helpers
|
||||
export {
|
||||
relativeDate,
|
||||
time,
|
||||
timestamp,
|
||||
todayAt,
|
||||
dateAt,
|
||||
duration,
|
||||
parseDuration,
|
||||
formatDuration,
|
||||
dateDiff,
|
||||
isWithinRange,
|
||||
dateSequence,
|
||||
mockTime,
|
||||
} from './time.js';
|
||||
|
||||
// Wait and polling helpers
|
||||
export {
|
||||
wait,
|
||||
nextTick,
|
||||
nextLoop,
|
||||
flushPromises,
|
||||
waitFor,
|
||||
waitForValue,
|
||||
waitForResult,
|
||||
waitForLength,
|
||||
waitForNoThrow,
|
||||
retry,
|
||||
withTimeout,
|
||||
concurrent,
|
||||
measureTime,
|
||||
assertFastEnough,
|
||||
type WaitForOptions,
|
||||
type RetryOptions,
|
||||
} from './wait.js';
|
||||
203
packages/test-utils/src/helpers/time.ts
Normal file
203
packages/test-utils/src/helpers/time.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Time Manipulation Helpers for Testing
|
||||
*
|
||||
* Utilities for working with dates, times, and durations in tests.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a date relative to now
|
||||
*/
|
||||
export function relativeDate(
|
||||
offset: number,
|
||||
unit: 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' = 'seconds'
|
||||
): Date {
|
||||
const now = new Date();
|
||||
const multipliers = {
|
||||
seconds: 1000,
|
||||
minutes: 60 * 1000,
|
||||
hours: 60 * 60 * 1000,
|
||||
days: 24 * 60 * 60 * 1000,
|
||||
weeks: 7 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
return new Date(now.getTime() + offset * multipliers[unit]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helpers for common relative dates
|
||||
*/
|
||||
export const time = {
|
||||
now: () => new Date(),
|
||||
|
||||
// Past
|
||||
secondsAgo: (n: number) => relativeDate(-n, 'seconds'),
|
||||
minutesAgo: (n: number) => relativeDate(-n, 'minutes'),
|
||||
hoursAgo: (n: number) => relativeDate(-n, 'hours'),
|
||||
daysAgo: (n: number) => relativeDate(-n, 'days'),
|
||||
weeksAgo: (n: number) => relativeDate(-n, 'weeks'),
|
||||
|
||||
// Future
|
||||
inSeconds: (n: number) => relativeDate(n, 'seconds'),
|
||||
inMinutes: (n: number) => relativeDate(n, 'minutes'),
|
||||
inHours: (n: number) => relativeDate(n, 'hours'),
|
||||
inDays: (n: number) => relativeDate(n, 'days'),
|
||||
inWeeks: (n: number) => relativeDate(n, 'weeks'),
|
||||
|
||||
// Specific points
|
||||
yesterday: () => relativeDate(-1, 'days'),
|
||||
tomorrow: () => relativeDate(1, 'days'),
|
||||
lastWeek: () => relativeDate(-1, 'weeks'),
|
||||
nextWeek: () => relativeDate(1, 'weeks'),
|
||||
|
||||
// Start/end of day
|
||||
startOfToday: () => {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
},
|
||||
endOfToday: () => {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a timestamp for database seeding
|
||||
*/
|
||||
export function timestamp(date: Date = new Date()): string {
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a date at a specific time today
|
||||
*/
|
||||
export function todayAt(hours: number, minutes = 0, seconds = 0): Date {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate(), hours, minutes, seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a date at a specific time on a given date
|
||||
*/
|
||||
export function dateAt(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
hours = 0,
|
||||
minutes = 0,
|
||||
seconds = 0
|
||||
): Date {
|
||||
return new Date(year, month - 1, day, hours, minutes, seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Duration helpers (in milliseconds)
|
||||
*/
|
||||
export const duration = {
|
||||
seconds: (n: number) => n * 1000,
|
||||
minutes: (n: number) => n * 60 * 1000,
|
||||
hours: (n: number) => n * 60 * 60 * 1000,
|
||||
days: (n: number) => n * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse duration string to milliseconds
|
||||
* Supports: 30s, 5m, 2h, 1d
|
||||
*/
|
||||
export function parseDuration(str: string): number {
|
||||
const match = str.match(/^(\d+)(s|m|h|d)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid duration format: ${str}`);
|
||||
}
|
||||
|
||||
const value = match[1]!;
|
||||
const unit = match[2]!;
|
||||
const num = parseInt(value, 10);
|
||||
|
||||
switch (unit) {
|
||||
case 's':
|
||||
return duration.seconds(num);
|
||||
case 'm':
|
||||
return duration.minutes(num);
|
||||
case 'h':
|
||||
return duration.hours(num);
|
||||
case 'd':
|
||||
return duration.days(num);
|
||||
default:
|
||||
throw new Error(`Unknown duration unit: ${unit}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format milliseconds as human-readable duration
|
||||
*/
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
if (ms < 3600000) return `${(ms / 60000).toFixed(1)}m`;
|
||||
if (ms < 86400000) return `${(ms / 3600000).toFixed(1)}h`;
|
||||
return `${(ms / 86400000).toFixed(1)}d`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate difference between two dates in specified unit
|
||||
*/
|
||||
export function dateDiff(
|
||||
date1: Date,
|
||||
date2: Date,
|
||||
unit: 'seconds' | 'minutes' | 'hours' | 'days' = 'seconds'
|
||||
): number {
|
||||
const diffMs = date2.getTime() - date1.getTime();
|
||||
const divisors = {
|
||||
seconds: 1000,
|
||||
minutes: 60 * 1000,
|
||||
hours: 60 * 60 * 1000,
|
||||
days: 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
return diffMs / divisors[unit];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date is within a range
|
||||
*/
|
||||
export function isWithinRange(date: Date, start: Date, end: Date): boolean {
|
||||
const ts = date.getTime();
|
||||
return ts >= start.getTime() && ts <= end.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a sequence of dates for time-series testing
|
||||
*/
|
||||
export function dateSequence(
|
||||
start: Date,
|
||||
count: number,
|
||||
interval: number,
|
||||
unit: 'seconds' | 'minutes' | 'hours' | 'days' = 'hours'
|
||||
): Date[] {
|
||||
const multipliers = {
|
||||
seconds: 1000,
|
||||
minutes: 60 * 1000,
|
||||
hours: 60 * 60 * 1000,
|
||||
days: 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
const intervalMs = interval * multipliers[unit];
|
||||
return Array.from({ length: count }, (_, i) => new Date(start.getTime() + i * intervalMs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock system time for tests (requires vi.useFakeTimers)
|
||||
* Returns a cleanup function
|
||||
*/
|
||||
export function mockTime(date: Date): () => void {
|
||||
const original = Date.now;
|
||||
const mockNow = date.getTime();
|
||||
|
||||
// Override Date.now
|
||||
Date.now = () => mockNow;
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
Date.now = original;
|
||||
};
|
||||
}
|
||||
257
packages/test-utils/src/helpers/wait.ts
Normal file
257
packages/test-utils/src/helpers/wait.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Wait and Polling Helpers for Testing
|
||||
*
|
||||
* Utilities for async waiting, polling, and timing in tests.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wait for a specified duration
|
||||
*/
|
||||
export function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for next tick (microtask queue flush)
|
||||
*/
|
||||
export function nextTick(): Promise<void> {
|
||||
return new Promise((resolve) => process.nextTick(resolve));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for next event loop iteration (macrotask queue flush)
|
||||
*/
|
||||
export function nextLoop(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all pending promises
|
||||
*/
|
||||
export async function flushPromises(): Promise<void> {
|
||||
await nextTick();
|
||||
await nextLoop();
|
||||
}
|
||||
|
||||
export interface WaitForOptions {
|
||||
timeout?: number;
|
||||
interval?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a condition to become true
|
||||
*/
|
||||
export async function waitFor(
|
||||
condition: () => boolean | Promise<boolean>,
|
||||
options: WaitForOptions = {}
|
||||
): Promise<void> {
|
||||
const { timeout = 5000, interval = 50, message = 'Condition not met within timeout' } = options;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
while (true) {
|
||||
const result = await condition();
|
||||
if (result) return;
|
||||
|
||||
if (Date.now() - startTime > timeout) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
await wait(interval);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a value to match expected
|
||||
*/
|
||||
export async function waitForValue<T>(
|
||||
getValue: () => T | Promise<T>,
|
||||
expected: T,
|
||||
options: WaitForOptions = {}
|
||||
): Promise<void> {
|
||||
const { message = `Value did not become ${JSON.stringify(expected)} within timeout` } = options;
|
||||
await waitFor(async () => (await getValue()) === expected, { ...options, message });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a function to return a truthy value and return it
|
||||
*/
|
||||
export async function waitForResult<T>(
|
||||
getValue: () => T | Promise<T>,
|
||||
options: WaitForOptions = {}
|
||||
): Promise<NonNullable<T>> {
|
||||
const { timeout = 5000, interval = 50, message = 'Did not get truthy result within timeout' } = options;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
while (true) {
|
||||
const result = await getValue();
|
||||
if (result) return result as NonNullable<T>;
|
||||
|
||||
if (Date.now() - startTime > timeout) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
await wait(interval);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an array to reach a certain length
|
||||
*/
|
||||
export async function waitForLength<T>(
|
||||
getArray: () => T[] | Promise<T[]>,
|
||||
length: number,
|
||||
options: WaitForOptions = {}
|
||||
): Promise<T[]> {
|
||||
const { message = `Array did not reach length ${length} within timeout` } = options;
|
||||
let array: T[] = [];
|
||||
|
||||
await waitFor(async () => {
|
||||
array = await getArray();
|
||||
return array.length >= length;
|
||||
}, { ...options, message });
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a function to not throw
|
||||
*/
|
||||
export async function waitForNoThrow(
|
||||
fn: () => void | Promise<void>,
|
||||
options: WaitForOptions = {}
|
||||
): Promise<void> {
|
||||
const { timeout = 5000, interval = 50, message = 'Function kept throwing within timeout' } = options;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
await fn();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (Date.now() - startTime > timeout) {
|
||||
throw new Error(`${message}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
await wait(interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface RetryOptions {
|
||||
maxAttempts?: number;
|
||||
delay?: number;
|
||||
backoff?: 'none' | 'linear' | 'exponential';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a function until it succeeds
|
||||
*/
|
||||
export async function retry<T>(
|
||||
fn: () => T | Promise<T>,
|
||||
options: RetryOptions = {}
|
||||
): Promise<T> {
|
||||
const { maxAttempts = 3, delay = 100, backoff = 'none' } = options;
|
||||
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
let waitTime = delay;
|
||||
if (backoff === 'linear') {
|
||||
waitTime = delay * attempt;
|
||||
} else if (backoff === 'exponential') {
|
||||
waitTime = delay * Math.pow(2, attempt - 1);
|
||||
}
|
||||
await wait(waitTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error('All retry attempts failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a function with a timeout
|
||||
*/
|
||||
export async function withTimeout<T>(
|
||||
fn: () => Promise<T>,
|
||||
timeout: number,
|
||||
message = 'Operation timed out'
|
||||
): Promise<T> {
|
||||
return Promise.race([
|
||||
fn(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(message)), timeout)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run multiple operations concurrently with a limit
|
||||
*/
|
||||
export async function concurrent<T, R>(
|
||||
items: T[],
|
||||
fn: (item: T) => Promise<R>,
|
||||
limit = 5
|
||||
): Promise<R[]> {
|
||||
const results: R[] = [];
|
||||
const executing: Promise<void>[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const p = Promise.resolve().then(async () => {
|
||||
const result = await fn(item);
|
||||
results.push(result);
|
||||
});
|
||||
|
||||
executing.push(p);
|
||||
|
||||
if (executing.length >= limit) {
|
||||
await Promise.race(executing);
|
||||
// Remove completed promise from tracking array (splice returns removed elements, void to ignore)
|
||||
void executing.splice(
|
||||
executing.findIndex((e) => e === p),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(executing);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure execution time of a function
|
||||
*/
|
||||
export async function measureTime<T>(fn: () => T | Promise<T>): Promise<{ result: T; duration: number }> {
|
||||
const start = performance.now();
|
||||
const result = await fn();
|
||||
const duration = performance.now() - start;
|
||||
return { result, duration };
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a function completes within a time limit
|
||||
*/
|
||||
export async function assertFastEnough<T>(
|
||||
fn: () => T | Promise<T>,
|
||||
maxDuration: number,
|
||||
message?: string
|
||||
): Promise<T> {
|
||||
const { result, duration } = await measureTime(fn);
|
||||
|
||||
if (duration > maxDuration) {
|
||||
throw new Error(
|
||||
message ?? `Operation took ${duration.toFixed(2)}ms, expected < ${maxDuration}ms`
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
212
packages/test-utils/src/index.ts
Normal file
212
packages/test-utils/src/index.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* @tracearr/test-utils
|
||||
*
|
||||
* Shared test utilities for Tracearr packages.
|
||||
* Provides database setup, factories, mocks, matchers, and helpers.
|
||||
*
|
||||
* @module @tracearr/test-utils
|
||||
*/
|
||||
|
||||
// Database utilities
|
||||
export {
|
||||
getTestPool,
|
||||
getTestDb,
|
||||
executeRawSql,
|
||||
closeTestPool,
|
||||
setupTestDb,
|
||||
isTestDbReady,
|
||||
waitForTestDb,
|
||||
resetTestDb,
|
||||
teardownTestDb,
|
||||
truncateTables,
|
||||
seedBasicOwner,
|
||||
seedMultipleUsers,
|
||||
seedUserWithSessions,
|
||||
seedViolationScenario,
|
||||
seedMobilePairing,
|
||||
type SeedResult,
|
||||
} from './db/index.js';
|
||||
|
||||
// Test factories
|
||||
export {
|
||||
// User factories
|
||||
buildUser,
|
||||
createTestUser,
|
||||
createTestOwner,
|
||||
createTestAdmin,
|
||||
createTestMember,
|
||||
createTestUsers,
|
||||
resetUserCounter,
|
||||
type UserData,
|
||||
type CreatedUser,
|
||||
// Server factories
|
||||
buildServer,
|
||||
createTestServer,
|
||||
createTestPlexServer,
|
||||
createTestJellyfinServer,
|
||||
createTestEmbyServer,
|
||||
createTestServers,
|
||||
resetServerCounter,
|
||||
type ServerData,
|
||||
type CreatedServer,
|
||||
type ServerType,
|
||||
// ServerUser factories
|
||||
buildServerUser,
|
||||
createTestServerUser,
|
||||
createTestServerAdmin,
|
||||
resetServerUserCounter,
|
||||
type ServerUserData,
|
||||
type CreatedServerUser,
|
||||
// Session factories
|
||||
buildSession,
|
||||
createTestSession,
|
||||
createActiveSession,
|
||||
createPausedSession,
|
||||
createStoppedSession,
|
||||
createEpisodeSession,
|
||||
createConcurrentSessions,
|
||||
resetSessionCounter,
|
||||
type SessionData,
|
||||
type CreatedSession,
|
||||
type SessionState,
|
||||
type MediaType,
|
||||
// Rule factories
|
||||
buildRule,
|
||||
createTestRule,
|
||||
createImpossibleTravelRule,
|
||||
createSimultaneousLocationsRule,
|
||||
createDeviceVelocityRule,
|
||||
createConcurrentStreamsRule,
|
||||
createGeoRestrictionRule,
|
||||
resetRuleCounter,
|
||||
type RuleData,
|
||||
type CreatedRule,
|
||||
type RuleType,
|
||||
type RuleParams,
|
||||
type ImpossibleTravelParams,
|
||||
type SimultaneousLocationsParams,
|
||||
type DeviceVelocityParams,
|
||||
type ConcurrentStreamsParams,
|
||||
type GeoRestrictionParams,
|
||||
// Violation factories
|
||||
buildViolation,
|
||||
createTestViolation,
|
||||
createLowViolation,
|
||||
createWarningViolation,
|
||||
createHighViolation,
|
||||
createAcknowledgedViolation,
|
||||
createImpossibleTravelViolation,
|
||||
createConcurrentStreamsViolation,
|
||||
resetViolationCounter,
|
||||
type ViolationData,
|
||||
type CreatedViolation,
|
||||
type ViolationSeverity,
|
||||
// Reset all
|
||||
resetAllFactoryCounters,
|
||||
} from './factories/index.js';
|
||||
|
||||
// Mock utilities
|
||||
export {
|
||||
// Redis mocks
|
||||
getMockRedis,
|
||||
createMockRedis,
|
||||
resetMockRedis,
|
||||
createSimpleMockRedis,
|
||||
type SimpleMockRedis,
|
||||
// Media server mocks
|
||||
createMockMediaServerClient,
|
||||
createMockPlexClient,
|
||||
createMockJellyfinClient,
|
||||
createMockEmbyClient,
|
||||
buildMockSession,
|
||||
buildMockUser,
|
||||
buildMockLibrary,
|
||||
resetMockMediaCounters,
|
||||
type IMediaServerClient,
|
||||
type MediaSession,
|
||||
type MediaUser,
|
||||
type MediaLibrary,
|
||||
type MockMediaServerClient,
|
||||
type MockMediaServerOptions,
|
||||
// Expo Push mocks
|
||||
createMockExpoPushClient,
|
||||
createMockPushNotificationService,
|
||||
resetExpoPushCounter,
|
||||
type PushTicket,
|
||||
type PushReceipt,
|
||||
type PushMessage,
|
||||
type SentNotification,
|
||||
type MockExpoPushClient,
|
||||
type MockExpoPushOptions,
|
||||
type MockPushNotificationService,
|
||||
// WebSocket mocks
|
||||
createMockSocketClient,
|
||||
createMockSocketServer,
|
||||
createMockGetIO,
|
||||
createMockBroadcasters,
|
||||
resetSocketCounter,
|
||||
waitForEvent,
|
||||
collectEvents,
|
||||
type SocketEvent,
|
||||
type MockSocketClient,
|
||||
type MockSocketServer,
|
||||
type TracearrServerEvent,
|
||||
type TracearrClientEvent,
|
||||
// Reset all mocks
|
||||
resetAllMocks,
|
||||
} from './mocks/index.js';
|
||||
|
||||
// Custom matchers
|
||||
export { installMatchers, type HTTPResponse, type ValidationErrorResponse } from './matchers/index.js';
|
||||
|
||||
// Helper utilities
|
||||
export {
|
||||
// Auth helpers
|
||||
generateTestToken,
|
||||
generateExpiredToken,
|
||||
generateInvalidToken,
|
||||
decodeToken,
|
||||
verifyTestToken,
|
||||
generateOwnerToken,
|
||||
generateAdminToken,
|
||||
generateMemberToken,
|
||||
authHeaders,
|
||||
ownerAuthHeaders,
|
||||
adminAuthHeaders,
|
||||
memberAuthHeaders,
|
||||
getTestJwtSecret,
|
||||
generateMobilePairingToken,
|
||||
generateQRPairingPayload,
|
||||
type TestTokenPayload,
|
||||
type GenerateTokenOptions,
|
||||
// Time helpers
|
||||
relativeDate,
|
||||
time,
|
||||
timestamp,
|
||||
todayAt,
|
||||
dateAt,
|
||||
duration,
|
||||
parseDuration,
|
||||
formatDuration,
|
||||
dateDiff,
|
||||
isWithinRange,
|
||||
dateSequence,
|
||||
mockTime,
|
||||
// Wait helpers
|
||||
wait,
|
||||
nextTick,
|
||||
nextLoop,
|
||||
flushPromises,
|
||||
waitFor,
|
||||
waitForValue,
|
||||
waitForResult,
|
||||
waitForLength,
|
||||
waitForNoThrow,
|
||||
retry,
|
||||
withTimeout,
|
||||
concurrent,
|
||||
measureTime,
|
||||
assertFastEnough,
|
||||
type WaitForOptions,
|
||||
type RetryOptions,
|
||||
} from './helpers/index.js';
|
||||
416
packages/test-utils/src/matchers/index.ts
Normal file
416
packages/test-utils/src/matchers/index.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* Custom Vitest Matchers for Tracearr Tests
|
||||
*
|
||||
* Domain-specific assertions for cleaner, more readable tests.
|
||||
*
|
||||
* @module @tracearr/test-utils/matchers
|
||||
*/
|
||||
|
||||
import { expect } from 'vitest';
|
||||
|
||||
/**
|
||||
* HTTP Response Matchers
|
||||
*/
|
||||
interface HTTPMatchers<R = unknown> {
|
||||
toHaveStatus(expected: number): R;
|
||||
toBeSuccessful(): R;
|
||||
toBeClientError(): R;
|
||||
toBeServerError(): R;
|
||||
toHaveHeader(name: string, value?: string): R;
|
||||
toHaveJsonBody(): R;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation Matchers
|
||||
*/
|
||||
interface ValidationMatchers<R = unknown> {
|
||||
toHaveValidationError(field?: string): R;
|
||||
toHaveValidationErrors(count: number): R;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date/Time Matchers
|
||||
*/
|
||||
interface DateMatchers<R = unknown> {
|
||||
toBeWithinSeconds(expected: Date, seconds: number): R;
|
||||
toBeRecent(seconds?: number): R;
|
||||
toBeBefore(date: Date): R;
|
||||
toBeAfter(date: Date): R;
|
||||
}
|
||||
|
||||
/**
|
||||
* Array Matchers
|
||||
*/
|
||||
interface ArrayMatchers<R = unknown> {
|
||||
toContainObjectWith(partial: Record<string, unknown>): R;
|
||||
toAllMatch(predicate: (item: unknown) => boolean): R;
|
||||
toBeSortedBy(key: string, order?: 'asc' | 'desc'): R;
|
||||
}
|
||||
|
||||
/**
|
||||
* UUID Matcher
|
||||
*/
|
||||
interface UUIDMatchers<R = unknown> {
|
||||
toBeUUID(): R;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend Vitest's expect with custom matchers
|
||||
*/
|
||||
declare module 'vitest' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface Assertion<T>
|
||||
extends HTTPMatchers,
|
||||
ValidationMatchers,
|
||||
DateMatchers,
|
||||
ArrayMatchers,
|
||||
UUIDMatchers {}
|
||||
interface AsymmetricMatchersContaining
|
||||
extends HTTPMatchers,
|
||||
ValidationMatchers,
|
||||
DateMatchers,
|
||||
ArrayMatchers,
|
||||
UUIDMatchers {}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP response-like object
|
||||
*/
|
||||
interface HTTPResponse {
|
||||
status?: number;
|
||||
statusCode?: number;
|
||||
headers?: Record<string, string | string[] | undefined> | Headers;
|
||||
body?: unknown;
|
||||
json?: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation error response
|
||||
*/
|
||||
interface ValidationErrorResponse {
|
||||
statusCode?: number;
|
||||
status?: number;
|
||||
error?: string;
|
||||
message?: string;
|
||||
validation?: {
|
||||
body?: Array<{ path: string[]; message: string }>;
|
||||
query?: Array<{ path: string[]; message: string }>;
|
||||
params?: Array<{ path: string[]; message: string }>;
|
||||
};
|
||||
issues?: Array<{ path: string[]; message: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install custom matchers
|
||||
*/
|
||||
export function installMatchers(): void {
|
||||
expect.extend({
|
||||
// HTTP Status Matchers
|
||||
toHaveStatus(received: HTTPResponse, expected: number) {
|
||||
const status = received.status ?? received.statusCode;
|
||||
const pass = status === expected;
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
pass
|
||||
? `Expected response not to have status ${expected}, but it did`
|
||||
: `Expected response to have status ${expected}, but got ${status}`,
|
||||
};
|
||||
},
|
||||
|
||||
toBeSuccessful(received: HTTPResponse) {
|
||||
const status = received.status ?? received.statusCode ?? 0;
|
||||
const pass = status >= 200 && status < 300;
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
pass
|
||||
? `Expected response not to be successful (2xx), but got ${status}`
|
||||
: `Expected response to be successful (2xx), but got ${status}`,
|
||||
};
|
||||
},
|
||||
|
||||
toBeClientError(received: HTTPResponse) {
|
||||
const status = received.status ?? received.statusCode ?? 0;
|
||||
const pass = status >= 400 && status < 500;
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
pass
|
||||
? `Expected response not to be client error (4xx), but got ${status}`
|
||||
: `Expected response to be client error (4xx), but got ${status}`,
|
||||
};
|
||||
},
|
||||
|
||||
toBeServerError(received: HTTPResponse) {
|
||||
const status = received.status ?? received.statusCode ?? 0;
|
||||
const pass = status >= 500 && status < 600;
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
pass
|
||||
? `Expected response not to be server error (5xx), but got ${status}`
|
||||
: `Expected response to be server error (5xx), but got ${status}`,
|
||||
};
|
||||
},
|
||||
|
||||
toHaveHeader(received: HTTPResponse, name: string, value?: string) {
|
||||
const headers = received.headers;
|
||||
if (!headers) {
|
||||
return {
|
||||
pass: false,
|
||||
message: () => `Expected response to have headers, but it has none`,
|
||||
};
|
||||
}
|
||||
|
||||
let headerValue: string | undefined;
|
||||
if (headers instanceof Headers) {
|
||||
headerValue = headers.get(name) ?? undefined;
|
||||
} else {
|
||||
const rawValue = headers[name.toLowerCase()] ?? headers[name];
|
||||
headerValue = Array.isArray(rawValue) ? rawValue[0] : rawValue;
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
const pass = headerValue !== undefined;
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
pass
|
||||
? `Expected response not to have header "${name}", but it did`
|
||||
: `Expected response to have header "${name}", but it was missing`,
|
||||
};
|
||||
}
|
||||
|
||||
const pass = headerValue === value;
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
pass
|
||||
? `Expected header "${name}" not to equal "${value}", but it did`
|
||||
: `Expected header "${name}" to equal "${value}", but got "${headerValue}"`,
|
||||
};
|
||||
},
|
||||
|
||||
toHaveJsonBody(received: HTTPResponse) {
|
||||
const headers = received.headers;
|
||||
let contentType: string | undefined;
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
contentType = headers.get('content-type') ?? undefined;
|
||||
} else if (headers) {
|
||||
const rawValue = headers['content-type'] ?? headers['Content-Type'];
|
||||
contentType = Array.isArray(rawValue) ? rawValue[0] : rawValue;
|
||||
}
|
||||
|
||||
const pass = contentType?.includes('application/json') ?? false;
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
pass
|
||||
? `Expected response not to have JSON body, but content-type was "${contentType}"`
|
||||
: `Expected response to have JSON body, but content-type was "${contentType}"`,
|
||||
};
|
||||
},
|
||||
|
||||
// Validation Matchers
|
||||
toHaveValidationError(received: ValidationErrorResponse, field?: string) {
|
||||
const status = received.status ?? received.statusCode;
|
||||
const isValidationError = status === 400 || status === 422;
|
||||
|
||||
if (!isValidationError) {
|
||||
return {
|
||||
pass: false,
|
||||
message: () =>
|
||||
`Expected a validation error response (400/422), but got status ${status}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!field) {
|
||||
return {
|
||||
pass: true,
|
||||
message: () => `Expected response not to be a validation error, but it was`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check various validation error formats
|
||||
const allIssues = [
|
||||
...(received.validation?.body ?? []),
|
||||
...(received.validation?.query ?? []),
|
||||
...(received.validation?.params ?? []),
|
||||
...(received.issues ?? []),
|
||||
];
|
||||
|
||||
const hasFieldError = allIssues.some((issue) => issue.path.includes(field));
|
||||
|
||||
return {
|
||||
pass: hasFieldError,
|
||||
message: () =>
|
||||
hasFieldError
|
||||
? `Expected no validation error for field "${field}", but found one`
|
||||
: `Expected validation error for field "${field}", but none found. Issues: ${JSON.stringify(allIssues)}`,
|
||||
};
|
||||
},
|
||||
|
||||
toHaveValidationErrors(received: ValidationErrorResponse, count: number) {
|
||||
const allIssues = [
|
||||
...(received.validation?.body ?? []),
|
||||
...(received.validation?.query ?? []),
|
||||
...(received.validation?.params ?? []),
|
||||
...(received.issues ?? []),
|
||||
];
|
||||
|
||||
const pass = allIssues.length === count;
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
pass
|
||||
? `Expected not to have ${count} validation errors, but it did`
|
||||
: `Expected ${count} validation errors, but got ${allIssues.length}`,
|
||||
};
|
||||
},
|
||||
|
||||
// Date/Time Matchers
|
||||
toBeWithinSeconds(received: Date | string | number, expected: Date, seconds: number) {
|
||||
const receivedDate = new Date(received);
|
||||
const expectedDate = new Date(expected);
|
||||
const diff = Math.abs(receivedDate.getTime() - expectedDate.getTime()) / 1000;
|
||||
const pass = diff <= seconds;
|
||||
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
pass
|
||||
? `Expected date not to be within ${seconds}s of ${expectedDate.toISOString()}, but was ${diff.toFixed(2)}s away`
|
||||
: `Expected date to be within ${seconds}s of ${expectedDate.toISOString()}, but was ${diff.toFixed(2)}s away`,
|
||||
};
|
||||
},
|
||||
|
||||
toBeRecent(received: Date | string | number, seconds = 60) {
|
||||
const receivedDate = new Date(received);
|
||||
const now = new Date();
|
||||
const diff = (now.getTime() - receivedDate.getTime()) / 1000;
|
||||
const pass = diff >= 0 && diff <= seconds;
|
||||
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
pass
|
||||
? `Expected date not to be within ${seconds}s of now, but was ${diff.toFixed(2)}s ago`
|
||||
: `Expected date to be within ${seconds}s of now, but was ${diff.toFixed(2)}s ago`,
|
||||
};
|
||||
},
|
||||
|
||||
toBeBefore(received: Date | string | number, expected: Date) {
|
||||
const receivedDate = new Date(received);
|
||||
const expectedDate = new Date(expected);
|
||||
const pass = receivedDate < expectedDate;
|
||||
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
pass
|
||||
? `Expected ${receivedDate.toISOString()} not to be before ${expectedDate.toISOString()}`
|
||||
: `Expected ${receivedDate.toISOString()} to be before ${expectedDate.toISOString()}`,
|
||||
};
|
||||
},
|
||||
|
||||
toBeAfter(received: Date | string | number, expected: Date) {
|
||||
const receivedDate = new Date(received);
|
||||
const expectedDate = new Date(expected);
|
||||
const pass = receivedDate > expectedDate;
|
||||
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
pass
|
||||
? `Expected ${receivedDate.toISOString()} not to be after ${expectedDate.toISOString()}`
|
||||
: `Expected ${receivedDate.toISOString()} to be after ${expectedDate.toISOString()}`,
|
||||
};
|
||||
},
|
||||
|
||||
// Array Matchers
|
||||
toContainObjectWith(received: unknown[], partial: Record<string, unknown>) {
|
||||
const found = received.some((item) => {
|
||||
if (typeof item !== 'object' || item === null) return false;
|
||||
const obj = item as Record<string, unknown>;
|
||||
return Object.entries(partial).every(([key, value]) => obj[key] === value);
|
||||
});
|
||||
|
||||
return {
|
||||
pass: found,
|
||||
message: () =>
|
||||
found
|
||||
? `Expected array not to contain object matching ${JSON.stringify(partial)}`
|
||||
: `Expected array to contain object matching ${JSON.stringify(partial)}`,
|
||||
};
|
||||
},
|
||||
|
||||
toAllMatch(received: unknown[], predicate: (item: unknown) => boolean) {
|
||||
const allMatch = received.every(predicate);
|
||||
const failingIndex = received.findIndex((item) => !predicate(item));
|
||||
|
||||
return {
|
||||
pass: allMatch,
|
||||
message: () =>
|
||||
allMatch
|
||||
? `Expected not all items to match predicate, but they did`
|
||||
: `Expected all items to match predicate, but item at index ${failingIndex} did not`,
|
||||
};
|
||||
},
|
||||
|
||||
toBeSortedBy(received: unknown[], key: string, order: 'asc' | 'desc' = 'asc') {
|
||||
if (received.length < 2) {
|
||||
return { pass: true, message: () => 'Array is too short to verify sorting' };
|
||||
}
|
||||
|
||||
const getValue = (item: unknown): string | number | undefined => {
|
||||
if (typeof item !== 'object' || item === null) return undefined;
|
||||
const val = (item as Record<string, unknown>)[key];
|
||||
if (typeof val === 'string' || typeof val === 'number') return val;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
let sorted = true;
|
||||
let failedAt = -1;
|
||||
|
||||
for (let i = 1; i < received.length; i++) {
|
||||
const prev = getValue(received[i - 1]);
|
||||
const curr = getValue(received[i]);
|
||||
|
||||
if (prev === undefined || curr === undefined) continue;
|
||||
const comparison = order === 'asc' ? prev > curr : prev < curr;
|
||||
if (comparison) {
|
||||
sorted = false;
|
||||
failedAt = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pass: sorted,
|
||||
message: () =>
|
||||
sorted
|
||||
? `Expected array not to be sorted by "${key}" (${order}), but it was`
|
||||
: `Expected array to be sorted by "${key}" (${order}), but failed at index ${failedAt}`,
|
||||
};
|
||||
},
|
||||
|
||||
// UUID Matcher
|
||||
toBeUUID(received: unknown) {
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const pass = typeof received === 'string' && uuidRegex.test(received);
|
||||
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
pass
|
||||
? `Expected "${received}" not to be a valid UUID, but it was`
|
||||
: `Expected "${received}" to be a valid UUID`,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Export for re-use
|
||||
export type { HTTPResponse, ValidationErrorResponse };
|
||||
318
packages/test-utils/src/mocks/expoPush.ts
Normal file
318
packages/test-utils/src/mocks/expoPush.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Expo Push Notification Mock for Testing
|
||||
*
|
||||
* Mock implementation of push notification service.
|
||||
* Tracks all notifications sent and allows assertions.
|
||||
*/
|
||||
|
||||
export interface PushTicket {
|
||||
id: string;
|
||||
status: 'ok' | 'error';
|
||||
message?: string;
|
||||
details?: {
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PushReceipt {
|
||||
id: string;
|
||||
status: 'ok' | 'error';
|
||||
message?: string;
|
||||
details?: {
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PushMessage {
|
||||
to: string | string[];
|
||||
title?: string;
|
||||
body: string;
|
||||
data?: Record<string, unknown>;
|
||||
sound?: 'default' | null;
|
||||
badge?: number;
|
||||
channelId?: string;
|
||||
priority?: 'default' | 'normal' | 'high';
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
export interface SentNotification {
|
||||
message: PushMessage;
|
||||
ticket: PushTicket;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface MockExpoPushOptions {
|
||||
defaultStatus?: 'ok' | 'error';
|
||||
errorMessage?: string;
|
||||
errorDetails?: { error: string };
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export interface MockExpoPushClient {
|
||||
sendPushNotificationsAsync(messages: PushMessage[]): Promise<PushTicket[]>;
|
||||
getPushNotificationReceiptsAsync(ticketIds: string[]): Promise<Record<string, PushReceipt>>;
|
||||
|
||||
_sentNotifications: SentNotification[];
|
||||
_receipts: Map<string, PushReceipt>;
|
||||
_setDefaultStatus: (status: 'ok' | 'error') => void;
|
||||
_setErrorMessage: (message: string) => void;
|
||||
_setErrorDetails: (details: { error: string }) => void;
|
||||
_setDelay: (delay: number) => void;
|
||||
_addReceipt: (ticketId: string, receipt: PushReceipt) => void;
|
||||
_reset: () => void;
|
||||
_getNotificationsByToken: (token: string) => SentNotification[];
|
||||
_getNotificationsByTitle: (title: string) => SentNotification[];
|
||||
_getNotificationCount: () => number;
|
||||
}
|
||||
|
||||
let ticketCounter = 0;
|
||||
|
||||
/**
|
||||
* Create a mock Expo push client
|
||||
*/
|
||||
export function createMockExpoPushClient(options: MockExpoPushOptions = {}): MockExpoPushClient {
|
||||
let defaultStatus = options.defaultStatus ?? 'ok';
|
||||
let errorMessage = options.errorMessage ?? 'Push notification failed';
|
||||
let errorDetails = options.errorDetails ?? { error: 'DeviceNotRegistered' };
|
||||
let delay = options.delay ?? 0;
|
||||
|
||||
const sentNotifications: SentNotification[] = [];
|
||||
const receipts = new Map<string, PushReceipt>();
|
||||
|
||||
return {
|
||||
_sentNotifications: sentNotifications,
|
||||
_receipts: receipts,
|
||||
|
||||
async sendPushNotificationsAsync(messages: PushMessage[]): Promise<PushTicket[]> {
|
||||
if (delay > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
const tickets: PushTicket[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const ticketId = `ticket-${++ticketCounter}`;
|
||||
const ticket: PushTicket =
|
||||
defaultStatus === 'ok'
|
||||
? { id: ticketId, status: 'ok' }
|
||||
: { id: ticketId, status: 'error', message: errorMessage, details: errorDetails };
|
||||
|
||||
tickets.push(ticket);
|
||||
sentNotifications.push({
|
||||
message,
|
||||
ticket,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
return tickets;
|
||||
},
|
||||
|
||||
async getPushNotificationReceiptsAsync(
|
||||
ticketIds: string[]
|
||||
): Promise<Record<string, PushReceipt>> {
|
||||
if (delay > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
const result: Record<string, PushReceipt> = {};
|
||||
|
||||
for (const ticketId of ticketIds) {
|
||||
if (receipts.has(ticketId)) {
|
||||
result[ticketId] = receipts.get(ticketId)!;
|
||||
} else {
|
||||
// Default receipt based on status
|
||||
result[ticketId] =
|
||||
defaultStatus === 'ok'
|
||||
? { id: ticketId, status: 'ok' }
|
||||
: { id: ticketId, status: 'error', message: errorMessage, details: errorDetails };
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
_setDefaultStatus(status: 'ok' | 'error') {
|
||||
defaultStatus = status;
|
||||
},
|
||||
|
||||
_setErrorMessage(message: string) {
|
||||
errorMessage = message;
|
||||
},
|
||||
|
||||
_setErrorDetails(details: { error: string }) {
|
||||
errorDetails = details;
|
||||
},
|
||||
|
||||
_setDelay(newDelay: number) {
|
||||
delay = newDelay;
|
||||
},
|
||||
|
||||
_addReceipt(ticketId: string, receipt: PushReceipt) {
|
||||
receipts.set(ticketId, receipt);
|
||||
},
|
||||
|
||||
_reset() {
|
||||
sentNotifications.length = 0;
|
||||
receipts.clear();
|
||||
ticketCounter = 0;
|
||||
defaultStatus = 'ok';
|
||||
errorMessage = 'Push notification failed';
|
||||
errorDetails = { error: 'DeviceNotRegistered' };
|
||||
delay = 0;
|
||||
},
|
||||
|
||||
_getNotificationsByToken(token: string): SentNotification[] {
|
||||
return sentNotifications.filter((n) => {
|
||||
const recipients = Array.isArray(n.message.to) ? n.message.to : [n.message.to];
|
||||
return recipients.includes(token);
|
||||
});
|
||||
},
|
||||
|
||||
_getNotificationsByTitle(title: string): SentNotification[] {
|
||||
return sentNotifications.filter((n) => n.message.title === title);
|
||||
},
|
||||
|
||||
_getNotificationCount(): number {
|
||||
return sentNotifications.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the ticket counter
|
||||
*/
|
||||
export function resetExpoPushCounter(): void {
|
||||
ticketCounter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock PushNotificationService for testing
|
||||
* Wraps the Expo client mock with higher-level notification methods
|
||||
*/
|
||||
export interface MockPushNotificationService {
|
||||
notifyViolation(
|
||||
tokens: string[],
|
||||
data: { username: string; ruleName: string; severity: string }
|
||||
): Promise<void>;
|
||||
notifySessionStarted(
|
||||
tokens: string[],
|
||||
data: { username: string; mediaTitle: string; serverName: string }
|
||||
): Promise<void>;
|
||||
notifySessionStopped(
|
||||
tokens: string[],
|
||||
data: { username: string; mediaTitle: string; serverName: string }
|
||||
): Promise<void>;
|
||||
notifyServerDown(tokens: string[], data: { serverName: string }): Promise<void>;
|
||||
notifyServerUp(tokens: string[], data: { serverName: string }): Promise<void>;
|
||||
|
||||
_client: MockExpoPushClient;
|
||||
_calls: {
|
||||
notifyViolation: number;
|
||||
notifySessionStarted: number;
|
||||
notifySessionStopped: number;
|
||||
notifyServerDown: number;
|
||||
notifyServerUp: number;
|
||||
};
|
||||
_reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock push notification service
|
||||
*/
|
||||
export function createMockPushNotificationService(
|
||||
options: MockExpoPushOptions = {}
|
||||
): MockPushNotificationService {
|
||||
const client = createMockExpoPushClient(options);
|
||||
const calls = {
|
||||
notifyViolation: 0,
|
||||
notifySessionStarted: 0,
|
||||
notifySessionStopped: 0,
|
||||
notifyServerDown: 0,
|
||||
notifyServerUp: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
_client: client,
|
||||
_calls: calls,
|
||||
|
||||
async notifyViolation(tokens, data) {
|
||||
calls.notifyViolation++;
|
||||
await client.sendPushNotificationsAsync(
|
||||
tokens.map((to) => ({
|
||||
to,
|
||||
title: '⚠️ Rule Violation',
|
||||
body: `${data.username} triggered ${data.ruleName} (${data.severity})`,
|
||||
data: { type: 'violation', ...data },
|
||||
sound: 'default',
|
||||
priority: 'high',
|
||||
}))
|
||||
);
|
||||
},
|
||||
|
||||
async notifySessionStarted(tokens, data) {
|
||||
calls.notifySessionStarted++;
|
||||
await client.sendPushNotificationsAsync(
|
||||
tokens.map((to) => ({
|
||||
to,
|
||||
title: '▶️ Stream Started',
|
||||
body: `${data.username} started watching ${data.mediaTitle} on ${data.serverName}`,
|
||||
data: { type: 'session_started', ...data },
|
||||
sound: null,
|
||||
priority: 'normal',
|
||||
}))
|
||||
);
|
||||
},
|
||||
|
||||
async notifySessionStopped(tokens, data) {
|
||||
calls.notifySessionStopped++;
|
||||
await client.sendPushNotificationsAsync(
|
||||
tokens.map((to) => ({
|
||||
to,
|
||||
title: '⏹️ Stream Ended',
|
||||
body: `${data.username} stopped watching ${data.mediaTitle} on ${data.serverName}`,
|
||||
data: { type: 'session_stopped', ...data },
|
||||
sound: null,
|
||||
priority: 'normal',
|
||||
}))
|
||||
);
|
||||
},
|
||||
|
||||
async notifyServerDown(tokens, data) {
|
||||
calls.notifyServerDown++;
|
||||
await client.sendPushNotificationsAsync(
|
||||
tokens.map((to) => ({
|
||||
to,
|
||||
title: '🔴 Server Down',
|
||||
body: `${data.serverName} is not responding`,
|
||||
data: { type: 'server_down', ...data },
|
||||
sound: 'default',
|
||||
priority: 'high',
|
||||
}))
|
||||
);
|
||||
},
|
||||
|
||||
async notifyServerUp(tokens, data) {
|
||||
calls.notifyServerUp++;
|
||||
await client.sendPushNotificationsAsync(
|
||||
tokens.map((to) => ({
|
||||
to,
|
||||
title: '🟢 Server Online',
|
||||
body: `${data.serverName} is back online`,
|
||||
data: { type: 'server_up', ...data },
|
||||
sound: 'default',
|
||||
priority: 'normal',
|
||||
}))
|
||||
);
|
||||
},
|
||||
|
||||
_reset() {
|
||||
client._reset();
|
||||
calls.notifyViolation = 0;
|
||||
calls.notifySessionStarted = 0;
|
||||
calls.notifySessionStopped = 0;
|
||||
calls.notifyServerDown = 0;
|
||||
calls.notifyServerUp = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
80
packages/test-utils/src/mocks/index.ts
Normal file
80
packages/test-utils/src/mocks/index.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Mock utilities for testing
|
||||
*
|
||||
* @module @tracearr/test-utils/mocks
|
||||
*/
|
||||
|
||||
// Import reset functions for local use in resetAllMocks()
|
||||
import { resetMockRedis } from './redis.js';
|
||||
import { resetMockMediaCounters } from './mediaServer.js';
|
||||
import { resetExpoPushCounter } from './expoPush.js';
|
||||
import { resetSocketCounter } from './websocket.js';
|
||||
|
||||
// Redis mocks
|
||||
export {
|
||||
getMockRedis,
|
||||
createMockRedis,
|
||||
resetMockRedis,
|
||||
createSimpleMockRedis,
|
||||
type SimpleMockRedis,
|
||||
} from './redis.js';
|
||||
|
||||
// Media server mocks (Plex, Jellyfin, Emby)
|
||||
export {
|
||||
createMockMediaServerClient,
|
||||
createMockPlexClient,
|
||||
createMockJellyfinClient,
|
||||
createMockEmbyClient,
|
||||
buildMockSession,
|
||||
buildMockUser,
|
||||
buildMockLibrary,
|
||||
resetMockMediaCounters,
|
||||
type IMediaServerClient,
|
||||
type MediaSession,
|
||||
type MediaUser,
|
||||
type MediaLibrary,
|
||||
type MockMediaServerClient,
|
||||
type MockMediaServerOptions,
|
||||
} from './mediaServer.js';
|
||||
|
||||
// Expo Push notification mocks
|
||||
export {
|
||||
createMockExpoPushClient,
|
||||
createMockPushNotificationService,
|
||||
resetExpoPushCounter,
|
||||
type PushTicket,
|
||||
type PushReceipt,
|
||||
type PushMessage,
|
||||
type SentNotification,
|
||||
type MockExpoPushClient,
|
||||
type MockExpoPushOptions,
|
||||
type MockPushNotificationService,
|
||||
} from './expoPush.js';
|
||||
|
||||
// WebSocket/Socket.io mocks
|
||||
export {
|
||||
createMockSocketClient,
|
||||
createMockSocketServer,
|
||||
createMockGetIO,
|
||||
createMockBroadcasters,
|
||||
resetSocketCounter,
|
||||
waitForEvent,
|
||||
collectEvents,
|
||||
type SocketEvent,
|
||||
type MockSocketClient,
|
||||
type MockSocketServer,
|
||||
type TracearrServerEvent,
|
||||
type TracearrClientEvent,
|
||||
} from './websocket.js';
|
||||
|
||||
/**
|
||||
* Reset all mock counters and state
|
||||
*
|
||||
* Call this in beforeEach() for clean test isolation
|
||||
*/
|
||||
export function resetAllMocks(): void {
|
||||
resetMockRedis();
|
||||
resetMockMediaCounters();
|
||||
resetExpoPushCounter();
|
||||
resetSocketCounter();
|
||||
}
|
||||
269
packages/test-utils/src/mocks/mediaServer.ts
Normal file
269
packages/test-utils/src/mocks/mediaServer.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Media Server Mock for Testing
|
||||
*
|
||||
* Mock implementation of IMediaServerClient interface for Plex, Jellyfin, and Emby.
|
||||
* Provides configurable responses and call tracking.
|
||||
*/
|
||||
|
||||
import type { ServerType } from '@tracearr/shared';
|
||||
|
||||
export interface MediaSession {
|
||||
sessionKey: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
mediaTitle: string;
|
||||
mediaType: 'movie' | 'episode' | 'track';
|
||||
grandparentTitle?: string;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
year?: number;
|
||||
thumbPath?: string;
|
||||
ratingKey?: string;
|
||||
state: 'playing' | 'paused' | 'stopped';
|
||||
progressMs: number;
|
||||
totalDurationMs: number;
|
||||
ipAddress: string;
|
||||
playerName?: string;
|
||||
deviceId?: string;
|
||||
product?: string;
|
||||
device?: string;
|
||||
platform?: string;
|
||||
quality?: string;
|
||||
isTranscode: boolean;
|
||||
bitrate?: number;
|
||||
}
|
||||
|
||||
export interface MediaUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
thumb?: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface MediaLibrary {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'movie' | 'show' | 'music' | 'photo';
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
export interface IMediaServerClient {
|
||||
readonly serverType: ServerType;
|
||||
getSessions(): Promise<MediaSession[]>;
|
||||
getUsers(): Promise<MediaUser[]>;
|
||||
getLibraries(): Promise<MediaLibrary[]>;
|
||||
testConnection(): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface MockMediaServerOptions {
|
||||
serverType?: ServerType;
|
||||
sessions?: MediaSession[];
|
||||
users?: MediaUser[];
|
||||
libraries?: MediaLibrary[];
|
||||
connectionStatus?: boolean;
|
||||
sessionError?: Error;
|
||||
userError?: Error;
|
||||
libraryError?: Error;
|
||||
connectionError?: Error;
|
||||
}
|
||||
|
||||
export interface MockMediaServerClient extends IMediaServerClient {
|
||||
_calls: {
|
||||
getSessions: number;
|
||||
getUsers: number;
|
||||
getLibraries: number;
|
||||
testConnection: number;
|
||||
};
|
||||
_setSessions: (sessions: MediaSession[]) => void;
|
||||
_setUsers: (users: MediaUser[]) => void;
|
||||
_setLibraries: (libraries: MediaLibrary[]) => void;
|
||||
_setConnectionStatus: (status: boolean) => void;
|
||||
_setSessionError: (error: Error | null) => void;
|
||||
_setUserError: (error: Error | null) => void;
|
||||
_setLibraryError: (error: Error | null) => void;
|
||||
_setConnectionError: (error: Error | null) => void;
|
||||
_reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock media server client
|
||||
*/
|
||||
export function createMockMediaServerClient(
|
||||
options: MockMediaServerOptions = {}
|
||||
): MockMediaServerClient {
|
||||
let sessions = options.sessions ?? [];
|
||||
let users = options.users ?? [];
|
||||
let libraries = options.libraries ?? [];
|
||||
let connectionStatus = options.connectionStatus ?? true;
|
||||
let sessionError = options.sessionError ?? null;
|
||||
let userError = options.userError ?? null;
|
||||
let libraryError = options.libraryError ?? null;
|
||||
let connectionError = options.connectionError ?? null;
|
||||
|
||||
const calls = {
|
||||
getSessions: 0,
|
||||
getUsers: 0,
|
||||
getLibraries: 0,
|
||||
testConnection: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
serverType: options.serverType ?? 'plex',
|
||||
_calls: calls,
|
||||
|
||||
async getSessions() {
|
||||
calls.getSessions++;
|
||||
if (sessionError) throw sessionError;
|
||||
return [...sessions];
|
||||
},
|
||||
|
||||
async getUsers() {
|
||||
calls.getUsers++;
|
||||
if (userError) throw userError;
|
||||
return [...users];
|
||||
},
|
||||
|
||||
async getLibraries() {
|
||||
calls.getLibraries++;
|
||||
if (libraryError) throw libraryError;
|
||||
return [...libraries];
|
||||
},
|
||||
|
||||
async testConnection() {
|
||||
calls.testConnection++;
|
||||
if (connectionError) throw connectionError;
|
||||
return connectionStatus;
|
||||
},
|
||||
|
||||
_setSessions(newSessions: MediaSession[]) {
|
||||
sessions = newSessions;
|
||||
},
|
||||
|
||||
_setUsers(newUsers: MediaUser[]) {
|
||||
users = newUsers;
|
||||
},
|
||||
|
||||
_setLibraries(newLibraries: MediaLibrary[]) {
|
||||
libraries = newLibraries;
|
||||
},
|
||||
|
||||
_setConnectionStatus(status: boolean) {
|
||||
connectionStatus = status;
|
||||
},
|
||||
|
||||
_setSessionError(error: Error | null) {
|
||||
sessionError = error;
|
||||
},
|
||||
|
||||
_setUserError(error: Error | null) {
|
||||
userError = error;
|
||||
},
|
||||
|
||||
_setLibraryError(error: Error | null) {
|
||||
libraryError = error;
|
||||
},
|
||||
|
||||
_setConnectionError(error: Error | null) {
|
||||
connectionError = error;
|
||||
},
|
||||
|
||||
_reset() {
|
||||
sessions = [];
|
||||
users = [];
|
||||
libraries = [];
|
||||
connectionStatus = true;
|
||||
sessionError = null;
|
||||
userError = null;
|
||||
libraryError = null;
|
||||
connectionError = null;
|
||||
calls.getSessions = 0;
|
||||
calls.getUsers = 0;
|
||||
calls.getLibraries = 0;
|
||||
calls.testConnection = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let sessionCounter = 0;
|
||||
|
||||
/**
|
||||
* Build a mock media session with defaults
|
||||
*/
|
||||
export function buildMockSession(overrides: Partial<MediaSession> = {}): MediaSession {
|
||||
const index = ++sessionCounter;
|
||||
return {
|
||||
sessionKey: `session-${index}`,
|
||||
userId: `user-${index}`,
|
||||
username: `testuser${index}`,
|
||||
mediaTitle: `Test Movie ${index}`,
|
||||
mediaType: 'movie',
|
||||
state: 'playing',
|
||||
progressMs: 0,
|
||||
totalDurationMs: 7200000,
|
||||
ipAddress: `192.168.1.${100 + (index % 155)}`,
|
||||
isTranscode: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
let userCounter = 0;
|
||||
|
||||
/**
|
||||
* Build a mock media user with defaults
|
||||
*/
|
||||
export function buildMockUser(overrides: Partial<MediaUser> = {}): MediaUser {
|
||||
const index = ++userCounter;
|
||||
return {
|
||||
id: `user-${index}`,
|
||||
name: `testuser${index}`,
|
||||
isAdmin: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
let libraryCounter = 0;
|
||||
|
||||
/**
|
||||
* Build a mock library with defaults
|
||||
*/
|
||||
export function buildMockLibrary(overrides: Partial<MediaLibrary> = {}): MediaLibrary {
|
||||
const index = ++libraryCounter;
|
||||
return {
|
||||
id: `lib-${index}`,
|
||||
name: `Library ${index}`,
|
||||
type: 'movie',
|
||||
itemCount: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all mock counters
|
||||
*/
|
||||
export function resetMockMediaCounters(): void {
|
||||
sessionCounter = 0;
|
||||
userCounter = 0;
|
||||
libraryCounter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock Plex client
|
||||
*/
|
||||
export function createMockPlexClient(options: Omit<MockMediaServerOptions, 'serverType'> = {}) {
|
||||
return createMockMediaServerClient({ ...options, serverType: 'plex' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock Jellyfin client
|
||||
*/
|
||||
export function createMockJellyfinClient(options: Omit<MockMediaServerOptions, 'serverType'> = {}) {
|
||||
return createMockMediaServerClient({ ...options, serverType: 'jellyfin' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock Emby client
|
||||
*/
|
||||
export function createMockEmbyClient(options: Omit<MockMediaServerOptions, 'serverType'> = {}) {
|
||||
return createMockMediaServerClient({ ...options, serverType: 'emby' });
|
||||
}
|
||||
189
packages/test-utils/src/mocks/redis.ts
Normal file
189
packages/test-utils/src/mocks/redis.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Redis Mock for Testing
|
||||
*
|
||||
* Uses ioredis-mock for in-memory Redis operations.
|
||||
* Provides both mock instance and factory functions.
|
||||
*/
|
||||
|
||||
import RedisMock from 'ioredis-mock';
|
||||
import type { Redis } from 'ioredis';
|
||||
|
||||
let mockRedisInstance: Redis | null = null;
|
||||
|
||||
/**
|
||||
* Get or create a singleton mock Redis instance
|
||||
*/
|
||||
export function getMockRedis(): Redis {
|
||||
if (!mockRedisInstance) {
|
||||
mockRedisInstance = new RedisMock() as unknown as Redis;
|
||||
}
|
||||
return mockRedisInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh mock Redis instance
|
||||
* Use when you need isolated Redis state per test
|
||||
*/
|
||||
export function createMockRedis(): Redis {
|
||||
return new RedisMock() as unknown as Redis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton mock Redis instance
|
||||
* Clears all data and creates a new instance
|
||||
*/
|
||||
export function resetMockRedis(): void {
|
||||
if (mockRedisInstance) {
|
||||
// ioredis-mock supports flushall - fire and forget for reset
|
||||
void (mockRedisInstance as Redis & { flushall: () => Promise<string> }).flushall?.();
|
||||
}
|
||||
mockRedisInstance = new RedisMock() as unknown as Redis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple mock Redis without ioredis-mock dependency
|
||||
* Useful for unit tests that don't need full Redis compatibility
|
||||
*/
|
||||
export function createSimpleMockRedis(): SimpleMockRedis {
|
||||
const store = new Map<string, string>();
|
||||
const sets = new Map<string, Set<string>>();
|
||||
const ttls = new Map<string, number>();
|
||||
const messageCallbacks: Array<(channel: string, message: string) => void> = [];
|
||||
|
||||
return {
|
||||
// Storage access for assertions
|
||||
_store: store,
|
||||
_sets: sets,
|
||||
_ttls: ttls,
|
||||
|
||||
// String operations
|
||||
get: async (key: string) => store.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
return 'OK';
|
||||
},
|
||||
setex: async (key: string, seconds: number, value: string) => {
|
||||
store.set(key, value);
|
||||
ttls.set(key, seconds);
|
||||
return 'OK';
|
||||
},
|
||||
del: async (...keys: string[]) => {
|
||||
let count = 0;
|
||||
for (const key of keys) {
|
||||
if (store.delete(key) || sets.delete(key)) count++;
|
||||
}
|
||||
return count;
|
||||
},
|
||||
keys: async (pattern: string) => {
|
||||
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
|
||||
return Array.from(store.keys()).filter((k) => regex.test(k));
|
||||
},
|
||||
exists: async (...keys: string[]) => {
|
||||
return keys.filter((k) => store.has(k) || sets.has(k)).length;
|
||||
},
|
||||
expire: async (key: string, seconds: number) => {
|
||||
ttls.set(key, seconds);
|
||||
return store.has(key) || sets.has(key) ? 1 : 0;
|
||||
},
|
||||
ttl: async (key: string) => {
|
||||
return ttls.get(key) ?? -2;
|
||||
},
|
||||
|
||||
// Set operations
|
||||
sadd: async (key: string, ...members: string[]) => {
|
||||
if (!sets.has(key)) sets.set(key, new Set());
|
||||
const set = sets.get(key)!;
|
||||
let added = 0;
|
||||
for (const member of members) {
|
||||
if (!set.has(member)) {
|
||||
set.add(member);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
},
|
||||
srem: async (key: string, ...members: string[]) => {
|
||||
const set = sets.get(key);
|
||||
if (!set) return 0;
|
||||
let removed = 0;
|
||||
for (const member of members) {
|
||||
if (set.delete(member)) removed++;
|
||||
}
|
||||
return removed;
|
||||
},
|
||||
smembers: async (key: string) => {
|
||||
const set = sets.get(key);
|
||||
return set ? Array.from(set) : [];
|
||||
},
|
||||
sismember: async (key: string, member: string) => {
|
||||
return sets.get(key)?.has(member) ? 1 : 0;
|
||||
},
|
||||
scard: async (key: string) => {
|
||||
return sets.get(key)?.size ?? 0;
|
||||
},
|
||||
|
||||
// Pub/Sub
|
||||
publish: async (_channel: string, _message: string) => 1,
|
||||
subscribe: async (_channel: string) => undefined,
|
||||
unsubscribe: async (_channel: string) => undefined,
|
||||
on: (event: string, callback: (channel: string, message: string) => void) => {
|
||||
if (event === 'message') {
|
||||
messageCallbacks.push(callback);
|
||||
}
|
||||
},
|
||||
|
||||
// Helper to simulate incoming message
|
||||
_simulateMessage: (channel: string, message: string) => {
|
||||
for (const cb of messageCallbacks) {
|
||||
cb(channel, message);
|
||||
}
|
||||
},
|
||||
|
||||
// Health
|
||||
ping: async () => 'PONG',
|
||||
|
||||
// Cleanup
|
||||
flushall: async () => {
|
||||
store.clear();
|
||||
sets.clear();
|
||||
ttls.clear();
|
||||
return 'OK';
|
||||
},
|
||||
|
||||
// Connection (no-op for mock)
|
||||
quit: async () => 'OK',
|
||||
disconnect: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SimpleMockRedis {
|
||||
_store: Map<string, string>;
|
||||
_sets: Map<string, Set<string>>;
|
||||
_ttls: Map<string, number>;
|
||||
_simulateMessage: (channel: string, message: string) => void;
|
||||
|
||||
get: (key: string) => Promise<string | null>;
|
||||
set: (key: string, value: string) => Promise<string>;
|
||||
setex: (key: string, seconds: number, value: string) => Promise<string>;
|
||||
del: (...keys: string[]) => Promise<number>;
|
||||
keys: (pattern: string) => Promise<string[]>;
|
||||
exists: (...keys: string[]) => Promise<number>;
|
||||
expire: (key: string, seconds: number) => Promise<number>;
|
||||
ttl: (key: string) => Promise<number>;
|
||||
|
||||
sadd: (key: string, ...members: string[]) => Promise<number>;
|
||||
srem: (key: string, ...members: string[]) => Promise<number>;
|
||||
smembers: (key: string) => Promise<string[]>;
|
||||
sismember: (key: string, member: string) => Promise<number>;
|
||||
scard: (key: string) => Promise<number>;
|
||||
|
||||
publish: (channel: string, message: string) => Promise<number>;
|
||||
subscribe: (channel: string) => Promise<void>;
|
||||
unsubscribe: (channel: string) => Promise<void>;
|
||||
on: (event: string, callback: (channel: string, message: string) => void) => void;
|
||||
|
||||
ping: () => Promise<string>;
|
||||
flushall: () => Promise<string>;
|
||||
quit: () => Promise<string>;
|
||||
disconnect: () => void;
|
||||
}
|
||||
327
packages/test-utils/src/mocks/websocket.ts
Normal file
327
packages/test-utils/src/mocks/websocket.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* WebSocket Mock for Testing
|
||||
*
|
||||
* Mock Socket.io client and server utilities for testing real-time events.
|
||||
*/
|
||||
|
||||
export interface SocketEvent {
|
||||
event: string;
|
||||
data: unknown;
|
||||
room?: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface MockSocketClient {
|
||||
id: string;
|
||||
connected: boolean;
|
||||
rooms: Set<string>;
|
||||
data: Record<string, unknown>;
|
||||
|
||||
emit(event: string, ...args: unknown[]): void;
|
||||
on(event: string, callback: (...args: unknown[]) => void): void;
|
||||
off(event: string, callback?: (...args: unknown[]) => void): void;
|
||||
join(room: string): void;
|
||||
leave(room: string): void;
|
||||
disconnect(): void;
|
||||
|
||||
_receivedEvents: SocketEvent[];
|
||||
_listeners: Map<string, Set<((...args: unknown[]) => void)>>;
|
||||
_simulateEvent: (event: string, ...args: unknown[]) => void;
|
||||
_reset: () => void;
|
||||
}
|
||||
|
||||
export interface MockSocketServer {
|
||||
sockets: Map<string, MockSocketClient>;
|
||||
rooms: Map<string, Set<string>>;
|
||||
|
||||
emit(event: string, ...args: unknown[]): void;
|
||||
to(room: string): { emit: (event: string, ...args: unknown[]) => void };
|
||||
in(room: string): { emit: (event: string, ...args: unknown[]) => void };
|
||||
|
||||
_emittedEvents: SocketEvent[];
|
||||
_createClient: (id?: string) => MockSocketClient;
|
||||
_removeClient: (id: string) => void;
|
||||
_broadcast: (event: string, data: unknown, room?: string) => void;
|
||||
_reset: () => void;
|
||||
}
|
||||
|
||||
let clientCounter = 0;
|
||||
|
||||
/**
|
||||
* Create a mock Socket.io client
|
||||
*/
|
||||
export function createMockSocketClient(id?: string): MockSocketClient {
|
||||
const clientId = id ?? `socket-${++clientCounter}`;
|
||||
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
|
||||
const receivedEvents: SocketEvent[] = [];
|
||||
const rooms = new Set<string>();
|
||||
|
||||
const client: MockSocketClient = {
|
||||
id: clientId,
|
||||
connected: true,
|
||||
rooms,
|
||||
data: {},
|
||||
|
||||
_receivedEvents: receivedEvents,
|
||||
_listeners: listeners,
|
||||
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
receivedEvents.push({
|
||||
event,
|
||||
data: args.length === 1 ? args[0] : args,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
},
|
||||
|
||||
on(event: string, callback: (...args: unknown[]) => void) {
|
||||
if (!listeners.has(event)) {
|
||||
listeners.set(event, new Set());
|
||||
}
|
||||
listeners.get(event)!.add(callback);
|
||||
},
|
||||
|
||||
off(event: string, callback?: (...args: unknown[]) => void) {
|
||||
if (callback) {
|
||||
listeners.get(event)?.delete(callback);
|
||||
} else {
|
||||
listeners.delete(event);
|
||||
}
|
||||
},
|
||||
|
||||
join(room: string) {
|
||||
rooms.add(room);
|
||||
},
|
||||
|
||||
leave(room: string) {
|
||||
rooms.delete(room);
|
||||
},
|
||||
|
||||
disconnect() {
|
||||
client.connected = false;
|
||||
rooms.clear();
|
||||
const disconnectCallbacks = listeners.get('disconnect');
|
||||
if (disconnectCallbacks) {
|
||||
for (const cb of disconnectCallbacks) {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_simulateEvent(event: string, ...args: unknown[]) {
|
||||
const callbacks = listeners.get(event);
|
||||
if (callbacks) {
|
||||
for (const cb of callbacks) {
|
||||
cb(...args);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_reset() {
|
||||
receivedEvents.length = 0;
|
||||
listeners.clear();
|
||||
rooms.clear();
|
||||
client.connected = true;
|
||||
client.data = {};
|
||||
},
|
||||
};
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock Socket.io server
|
||||
*/
|
||||
export function createMockSocketServer(): MockSocketServer {
|
||||
const sockets = new Map<string, MockSocketClient>();
|
||||
const rooms = new Map<string, Set<string>>();
|
||||
const emittedEvents: SocketEvent[] = [];
|
||||
|
||||
const server: MockSocketServer = {
|
||||
sockets,
|
||||
rooms,
|
||||
_emittedEvents: emittedEvents,
|
||||
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
const data = args.length === 1 ? args[0] : args;
|
||||
emittedEvents.push({
|
||||
event,
|
||||
data,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
// Emit to all connected clients
|
||||
for (const client of sockets.values()) {
|
||||
if (client.connected) {
|
||||
client._simulateEvent(event, ...args);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
to(room: string) {
|
||||
return {
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
const data = args.length === 1 ? args[0] : args;
|
||||
emittedEvents.push({
|
||||
event,
|
||||
data,
|
||||
room,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
// Emit to clients in the room
|
||||
const clientIds = rooms.get(room);
|
||||
if (clientIds) {
|
||||
for (const clientId of clientIds) {
|
||||
const client = sockets.get(clientId);
|
||||
if (client?.connected) {
|
||||
client._simulateEvent(event, ...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
in(room: string) {
|
||||
return server.to(room);
|
||||
},
|
||||
|
||||
_createClient(id?: string) {
|
||||
const client = createMockSocketClient(id);
|
||||
sockets.set(client.id, client);
|
||||
|
||||
// Track room membership
|
||||
const originalJoin = client.join.bind(client);
|
||||
client.join = (room: string) => {
|
||||
originalJoin(room);
|
||||
if (!rooms.has(room)) {
|
||||
rooms.set(room, new Set());
|
||||
}
|
||||
rooms.get(room)!.add(client.id);
|
||||
};
|
||||
|
||||
const originalLeave = client.leave.bind(client);
|
||||
client.leave = (room: string) => {
|
||||
originalLeave(room);
|
||||
rooms.get(room)?.delete(client.id);
|
||||
};
|
||||
|
||||
const originalDisconnect = client.disconnect.bind(client);
|
||||
client.disconnect = () => {
|
||||
originalDisconnect();
|
||||
// Remove from all rooms
|
||||
for (const roomClients of rooms.values()) {
|
||||
roomClients.delete(client.id);
|
||||
}
|
||||
};
|
||||
|
||||
return client;
|
||||
},
|
||||
|
||||
_removeClient(id: string) {
|
||||
const client = sockets.get(id);
|
||||
if (client) {
|
||||
client.disconnect();
|
||||
sockets.delete(id);
|
||||
}
|
||||
},
|
||||
|
||||
_broadcast(event: string, data: unknown, room?: string) {
|
||||
if (room) {
|
||||
server.to(room).emit(event, data);
|
||||
} else {
|
||||
server.emit(event, data);
|
||||
}
|
||||
},
|
||||
|
||||
_reset() {
|
||||
for (const client of sockets.values()) {
|
||||
client._reset();
|
||||
}
|
||||
sockets.clear();
|
||||
rooms.clear();
|
||||
emittedEvents.length = 0;
|
||||
clientCounter = 0;
|
||||
},
|
||||
};
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset client counter
|
||||
*/
|
||||
export function resetSocketCounter(): void {
|
||||
clientCounter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock getIO function for testing
|
||||
*/
|
||||
export function createMockGetIO(server: MockSocketServer): () => MockSocketServer {
|
||||
return () => server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mock broadcast functions
|
||||
*/
|
||||
export function createMockBroadcasters(server: MockSocketServer) {
|
||||
return {
|
||||
broadcastToSessions: (event: string, data: unknown) => {
|
||||
server.to('sessions').emit(event, data);
|
||||
},
|
||||
broadcastToServer: (serverId: string, event: string, data: unknown) => {
|
||||
server.to(`server:${serverId}`).emit(event, data);
|
||||
},
|
||||
broadcastToUser: (userId: string, event: string, data: unknown) => {
|
||||
server.to(`user:${userId}`).emit(event, data);
|
||||
},
|
||||
broadcastToAll: (event: string, data: unknown) => {
|
||||
server.emit(event, data);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed events for Tracearr WebSocket
|
||||
*/
|
||||
export type TracearrServerEvent =
|
||||
| 'session:started'
|
||||
| 'session:stopped'
|
||||
| 'session:updated'
|
||||
| 'violation:new'
|
||||
| 'stats:updated'
|
||||
| 'import:progress';
|
||||
|
||||
export type TracearrClientEvent = 'subscribe:sessions' | 'unsubscribe:sessions';
|
||||
|
||||
/**
|
||||
* Helper to wait for a specific event
|
||||
*/
|
||||
export function waitForEvent(
|
||||
client: MockSocketClient,
|
||||
event: string,
|
||||
timeout = 1000
|
||||
): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`Timeout waiting for event: ${event}`));
|
||||
}, timeout);
|
||||
|
||||
client.on(event, (data: unknown) => {
|
||||
clearTimeout(timer);
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to collect multiple events
|
||||
*/
|
||||
export function collectEvents(client: MockSocketClient, event: string, count: number): unknown[] {
|
||||
const collected: unknown[] = [];
|
||||
client.on(event, (data: unknown) => {
|
||||
if (collected.length < count) {
|
||||
collected.push(data);
|
||||
}
|
||||
});
|
||||
return collected;
|
||||
}
|
||||
18
packages/test-utils/src/types/ioredis-mock.d.ts
vendored
Normal file
18
packages/test-utils/src/types/ioredis-mock.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Type declarations for ioredis-mock
|
||||
*
|
||||
* This module provides a mock implementation of ioredis for testing.
|
||||
*/
|
||||
|
||||
declare module 'ioredis-mock' {
|
||||
import type { RedisOptions } from 'ioredis';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
|
||||
class RedisMock {
|
||||
constructor(options?: RedisOptions);
|
||||
// The mock implements the same interface as Redis
|
||||
// We use it via `as unknown as Redis` casting
|
||||
}
|
||||
|
||||
export = RedisMock;
|
||||
}
|
||||
168
packages/test-utils/src/vitest.setup.ts
Normal file
168
packages/test-utils/src/vitest.setup.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Vitest Setup for Integration Tests
|
||||
*
|
||||
* This file is imported by vitest.config.ts to set up the test environment.
|
||||
* For integration tests, it handles database setup, migration, and cleanup.
|
||||
*
|
||||
* Usage in vitest.config.ts:
|
||||
* setupFiles: ['@tracearr/test-utils/vitest.setup']
|
||||
*/
|
||||
|
||||
import { beforeEach, afterEach } from 'vitest';
|
||||
import { installMatchers } from './matchers/index.js';
|
||||
import { resetAllFactoryCounters } from './factories/index.js';
|
||||
import { resetAllMocks } from './mocks/index.js';
|
||||
import type { SeedResult } from './db/seed.js';
|
||||
|
||||
// Install custom matchers
|
||||
installMatchers();
|
||||
|
||||
/**
|
||||
* Shared test lifecycle hooks
|
||||
*
|
||||
* These provide reasonable defaults but can be overridden in individual test files.
|
||||
*/
|
||||
|
||||
// Reset factories and mocks before each test
|
||||
beforeEach(() => {
|
||||
resetAllFactoryCounters();
|
||||
resetAllMocks();
|
||||
});
|
||||
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
// Any per-test cleanup
|
||||
});
|
||||
|
||||
/**
|
||||
* Integration test setup (requires database)
|
||||
*
|
||||
* Call this in your integration test's beforeAll/afterAll hooks:
|
||||
*
|
||||
* ```typescript
|
||||
* import { setupIntegrationTests } from '@tracearr/test-utils/vitest.setup';
|
||||
*
|
||||
* const cleanup = setupIntegrationTests();
|
||||
*
|
||||
* afterAll(() => cleanup());
|
||||
* ```
|
||||
*/
|
||||
export async function setupIntegrationTests(): Promise<() => Promise<void>> {
|
||||
const { setupTestDb } = await import('./db/setup.js');
|
||||
const { closeTestPool } = await import('./db/pool.js');
|
||||
|
||||
// Set up database before all tests
|
||||
await setupTestDb();
|
||||
|
||||
// Return cleanup function
|
||||
return async () => {
|
||||
await closeTestPool();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-test database reset for integration tests
|
||||
*
|
||||
* Call this in beforeEach for tests that need a clean database:
|
||||
*
|
||||
* ```typescript
|
||||
* import { resetDatabaseBeforeEach } from '@tracearr/test-utils/vitest.setup';
|
||||
*
|
||||
* beforeEach(async () => {
|
||||
* await resetDatabaseBeforeEach();
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function resetDatabaseBeforeEach(): Promise<void> {
|
||||
const { resetTestDb } = await import('./db/reset.js');
|
||||
await resetTestDb();
|
||||
resetAllFactoryCounters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed database with standard test data
|
||||
*
|
||||
* Call this after resetDatabaseBeforeEach if you need pre-populated data:
|
||||
*
|
||||
* ```typescript
|
||||
* import { resetDatabaseBeforeEach, seedDatabaseForTest } from '@tracearr/test-utils/vitest.setup';
|
||||
*
|
||||
* beforeEach(async () => {
|
||||
* await resetDatabaseBeforeEach();
|
||||
* const data = await seedDatabaseForTest();
|
||||
* // data.userId, data.serverId, data.serverUserId are now available
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function seedDatabaseForTest(): Promise<SeedResult> {
|
||||
const { seedBasicOwner } = await import('./db/seed.js');
|
||||
return seedBasicOwner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unit test setup (no database required)
|
||||
*
|
||||
* For unit tests that only need mocks and helpers.
|
||||
* This is automatically applied via the global beforeEach above.
|
||||
*/
|
||||
export function setupUnitTests(): void {
|
||||
installMatchers();
|
||||
resetAllFactoryCounters();
|
||||
resetAllMocks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test context for integration tests
|
||||
*
|
||||
* Provides a convenient way to set up and tear down test context:
|
||||
*
|
||||
* ```typescript
|
||||
* import { createTestContext } from '@tracearr/test-utils/vitest.setup';
|
||||
*
|
||||
* describe('MyFeature', () => {
|
||||
* const ctx = createTestContext();
|
||||
*
|
||||
* it('should work', async () => {
|
||||
* const { owner, server } = await ctx.seed();
|
||||
* // test using seeded data
|
||||
* });
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createTestContext() {
|
||||
let seededData: SeedResult | null = null;
|
||||
let cleanupFn: (() => Promise<void>) | null = null;
|
||||
|
||||
return {
|
||||
async setup() {
|
||||
cleanupFn = await setupIntegrationTests();
|
||||
},
|
||||
|
||||
async reset() {
|
||||
await resetDatabaseBeforeEach();
|
||||
seededData = null;
|
||||
},
|
||||
|
||||
async seed() {
|
||||
await this.reset();
|
||||
seededData = await seedDatabaseForTest();
|
||||
return seededData;
|
||||
},
|
||||
|
||||
getData() {
|
||||
if (!seededData) {
|
||||
throw new Error('Test data not seeded. Call ctx.seed() first.');
|
||||
}
|
||||
return seededData;
|
||||
},
|
||||
|
||||
async cleanup() {
|
||||
if (cleanupFn) {
|
||||
await cleanupFn();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Export types for consumers
|
||||
export type TestContext = ReturnType<typeof createTestContext>;
|
||||
9
packages/test-utils/tsconfig.json
Normal file
9
packages/test-utils/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user