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

This commit is contained in:
2025-12-17 12:32:50 +13:00
commit 3015f48118
471 changed files with 141143 additions and 0 deletions

View 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"
}
}

View 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;

View 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';

View 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>;

View 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
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View 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"
}
}

View 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';

View 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);
}

View 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`
);
}

View 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 };
}

View 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`);
}

View 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();
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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')}`;
}

View 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';

View 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;
};
}

View 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;
}

View 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';

View 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 };

View 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;
},
};
}

View 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();
}

View 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' });
}

View 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;
}

View 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;
}

View 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;
}

View 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>;

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}