major refactoring and use spotify polling service instead of update every second
This commit is contained in:
@@ -1,45 +0,0 @@
|
||||
import axios from "axios";
|
||||
|
||||
export interface CurrentlyPlaying {
|
||||
timestamp: number;
|
||||
context?: {
|
||||
type: string;
|
||||
uri: string;
|
||||
};
|
||||
progress_ms?: number;
|
||||
item?: {
|
||||
name: string;
|
||||
artists: {
|
||||
name: string;
|
||||
uri: string;
|
||||
}[];
|
||||
album: {
|
||||
name: string;
|
||||
uri: string;
|
||||
};
|
||||
duration_ms: number;
|
||||
};
|
||||
is_playing: boolean;
|
||||
}
|
||||
|
||||
|
||||
export async function getCurrentlyPlaying(accessToken: string) {
|
||||
try {
|
||||
const response = await axios.get<CurrentlyPlaying>("https://api.spotify.com/v1/me/player/currently-playing", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}, params: {
|
||||
additional_types: "episode"
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
console.log("Es wird gerade nichts abgespielt.");
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("Fehler bei der Anfrage:", error.response?.status, error.response?.data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface CurrentlyPlaying {
|
||||
timestamp: number;
|
||||
context?: {
|
||||
type: string;
|
||||
uri: string;
|
||||
};
|
||||
progress_ms?: number;
|
||||
item?: {
|
||||
name: string;
|
||||
artists: {
|
||||
name: string;
|
||||
uri: string;
|
||||
}[];
|
||||
album: {
|
||||
name: string;
|
||||
uri: string;
|
||||
};
|
||||
duration_ms: number;
|
||||
};
|
||||
is_playing: boolean;
|
||||
}
|
||||
+2
-3
@@ -1,13 +1,12 @@
|
||||
import express from "express";
|
||||
import {UserService} from "../db/services/db/UserService";
|
||||
import {CreateUserPayload, IUser} from "../db/models/user";
|
||||
import {CreateUserPayload} from "../db/models/user";
|
||||
import {JwtAuthenticator} from "../utils/jwtAuthenticator";
|
||||
import crypto from "crypto";
|
||||
import {PasswordUtils} from "../utils/passwordUtils";
|
||||
import {asyncHandler} from "./middleware/asyncHandler";
|
||||
import {validateBody, v} from "./middleware/validate";
|
||||
import {ok, badRequest, unauthorized, created, conflict, notFound} from "./utils/responses";
|
||||
import {Document} from "mongoose";
|
||||
import {UserService} from "../services/db/UserService";
|
||||
|
||||
export class RestAuth {
|
||||
private readonly userService: UserService;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import type { UserService } from "../../db/services/db/UserService";
|
||||
import { notFound } from "../utils/responses";
|
||||
import {UserService} from "../../services/db/UserService";
|
||||
|
||||
export function isAdmin(userService: UserService) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import express from "express";
|
||||
import {UserService} from "../db/services/db/UserService";
|
||||
import {PasswordUtils} from "../utils/passwordUtils";
|
||||
import {asyncHandler} from "./middleware/asyncHandler";
|
||||
import {v, validateBody, validateParams} from "./middleware/validate";
|
||||
import {badRequest, ok} from "./utils/responses";
|
||||
import {isAdmin} from "./middleware/isAdmin";
|
||||
import {UserService} from "../services/db/UserService";
|
||||
|
||||
export class RestUser {
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import express from "express";
|
||||
import {SpotifyTokenService} from "../db/services/spotifyTokenService";
|
||||
import {asyncHandler} from "./middleware/asyncHandler";
|
||||
import {validateBody, v} from "./middleware/validate";
|
||||
import {ok, internalError} from "./utils/responses";
|
||||
import {SpotifyTokenService} from "../services/spotifyTokenService";
|
||||
|
||||
export class SpotifyTokenGenerator {
|
||||
|
||||
|
||||
+8
-4
@@ -12,12 +12,14 @@ import { SpotifyTokenGenerator } from "./rest/spotifyTokenGenerator";
|
||||
import { RestAuth } from "./rest/auth";
|
||||
import { authLimiter, spotifyLimiter } from "./rest/middleware/rateLimit";
|
||||
import { extractTokenFromCookie } from "./rest/middleware/extractTokenFromCookie";
|
||||
import { UserService } from "./db/services/db/UserService";
|
||||
import { JwtAuthenticator } from "./utils/jwtAuthenticator";
|
||||
import { authenticateJwt } from "./rest/middleware/authenticateJwt";
|
||||
import { connectToDatabase, disconnectFromDatabase } from "./db/services/db/database.service";
|
||||
import { SpotifyTokenService } from "./db/services/spotifyTokenService";
|
||||
import {watchUserChanges} from "./db/models/userWatch";
|
||||
import {SpotifyPollingService} from "./services/spotifyPollingService";
|
||||
import {SpotifyApiService} from "./services/spotifyApiService";
|
||||
import {UserService} from "./services/db/UserService";
|
||||
import {connectToDatabase, disconnectFromDatabase} from "./services/db/database.service";
|
||||
import {SpotifyTokenService} from "./services/spotifyTokenService";
|
||||
|
||||
interface ServerConfig {
|
||||
port: number;
|
||||
@@ -47,6 +49,8 @@ export class Server {
|
||||
|
||||
this.userService = await UserService.create();
|
||||
const spotifyTokenService = new SpotifyTokenService(this.config.spotifyClientId, this.config.spotifyClientSecret);
|
||||
const spotifyApiService = new SpotifyApiService();
|
||||
const spotifyPollingService = new SpotifyPollingService(this.userService, spotifyApiService, spotifyTokenService);
|
||||
|
||||
this._setupMiddleware();
|
||||
this._setupRoutes(this.userService, spotifyTokenService);
|
||||
@@ -56,7 +60,7 @@ export class Server {
|
||||
console.log(`Server is running on port ${this.config.port}`);
|
||||
});
|
||||
|
||||
this.webSocketServer = new ExtendedWebSocketServer(this.httpServer, this.userService, spotifyTokenService);
|
||||
this.webSocketServer = new ExtendedWebSocketServer(this.httpServer, this.userService, spotifyPollingService);
|
||||
|
||||
this._setupGracefulShutdown();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {CreateUserPayload, IUser, SpotifyConfig, UserModel} from "../../models/user";
|
||||
import {connectToDatabase} from "./database.service";
|
||||
import { UpdateQuery} from "mongoose";
|
||||
import {CreateUserPayload, IUser, SpotifyConfig, UserModel} from "../../db/models/user";
|
||||
|
||||
export class UserService {
|
||||
private static _instance: UserService;
|
||||
@@ -22,6 +22,14 @@ export class UserService {
|
||||
}).exec();
|
||||
}
|
||||
|
||||
public async updateUserByUUID(uuid: string, updates: Partial<IUser>): Promise<IUser | null> {
|
||||
return await UserModel.findOneAndUpdate(
|
||||
{ uuid: uuid },
|
||||
{ $set: updates },
|
||||
{ new: true }
|
||||
).exec();
|
||||
}
|
||||
|
||||
public async getAllUsers(): Promise<IUser[]> {
|
||||
return await UserModel.find({}, {spotifyConfig: 0, lastState: 0}).exec();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import axios, { AxiosError } from "axios";
|
||||
import {CurrentlyPlaying} from "../interfaces/CurrentlyPlaying";
|
||||
|
||||
export class SpotifyApiService {
|
||||
private readonly apiUrl = "https://api.spotify.com/v1";
|
||||
|
||||
public async getCurrentlyPlaying(accessToken: string): Promise<CurrentlyPlaying | null> {
|
||||
try {
|
||||
const response = await axios.get<CurrentlyPlaying>(`${this.apiUrl}/me/player/currently-playing`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
params: {
|
||||
additional_types: "episode"
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.error("Spotify API Error:", error.response?.status, error.response?.data);
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { appEventBus, SPOTIFY_STATE_UPDATED_EVENT } from "../utils/eventBus";
|
||||
import { SpotifyApiService } from "./spotifyApiService";
|
||||
import { IUser } from "../db/models/user";
|
||||
import { AxiosError } from "axios";
|
||||
import {UserService} from "./db/UserService";
|
||||
import {SpotifyTokenService} from "./spotifyTokenService";
|
||||
|
||||
const userStateCache = new Map<string, any>();
|
||||
const activePolls = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
export class SpotifyPollingService {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly spotifyApiService: SpotifyApiService,
|
||||
private readonly spotifyTokenService: SpotifyTokenService,
|
||||
) {}
|
||||
|
||||
public startPollingForUser(user: IUser): void {
|
||||
const uuid = user.uuid;
|
||||
if (activePolls.has(uuid)) return;
|
||||
|
||||
console.log(`[SpotifyPolling] Starting polling for user ${uuid}`);
|
||||
const intervalId = setInterval(() => this._pollUser(uuid), 3000); // Sicherer 3-Sekunden-Intervall
|
||||
activePolls.set(uuid, intervalId);
|
||||
|
||||
this._pollUser(uuid);
|
||||
}
|
||||
|
||||
public stopPollingForUser(uuid: string): void {
|
||||
if (activePolls.has(uuid)) {
|
||||
console.log(`[SpotifyPolling] Stopping polling for user ${uuid}`);
|
||||
clearInterval(activePolls.get(uuid)!);
|
||||
activePolls.delete(uuid);
|
||||
userStateCache.delete(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
private async _pollUser(uuid: string): Promise<void> {
|
||||
let user = await this.userService.getUserByUUID(uuid);
|
||||
if (!user || !user.spotifyConfig) {
|
||||
this.stopPollingForUser(uuid);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (Date.now() > user.spotifyConfig.expirationDate.getTime()) {
|
||||
console.log(`[SpotifyPolling] Token for ${uuid} expired, refreshing...`);
|
||||
const token = await this.spotifyTokenService.refreshToken(user.spotifyConfig.refreshToken);
|
||||
const newConfig = {
|
||||
refreshToken: user.spotifyConfig.refreshToken,
|
||||
accessToken: token.access_token,
|
||||
expirationDate: new Date(Date.now() + token.expires_in * 1000),
|
||||
scope: token.scope,
|
||||
};
|
||||
user = await this.userService.updateUserByUUID(uuid, { spotifyConfig: newConfig });
|
||||
|
||||
console.log(`[SpotifyPolling] Token for ${uuid} refreshed.`);
|
||||
}
|
||||
|
||||
const currentState = await this.spotifyApiService.getCurrentlyPlaying(user!.spotifyConfig!.accessToken);
|
||||
const lastState = userStateCache.get(uuid);
|
||||
|
||||
if (this._hasStateChanged(lastState, currentState)) {
|
||||
console.log(`[SpotifyPolling] State change for ${uuid}. Emitting event.`);
|
||||
userStateCache.set(uuid, currentState);
|
||||
appEventBus.emit(SPOTIFY_STATE_UPDATED_EVENT, { uuid, state: currentState });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError && error.response) {
|
||||
if (error.response.status === 429) {
|
||||
const retryAfter = Number(error.response.headers['retry-after'] || 5);
|
||||
console.warn(`[SpotifyPolling] Rate limit for ${uuid}. Pausing for ${retryAfter}s.`);
|
||||
this._pausePolling(uuid, retryAfter * 1000);
|
||||
} else if (error.response.status === 401) {
|
||||
console.error(`[SpotifyPolling] Bad token for ${uuid}. Stopping poll.`);
|
||||
this.stopPollingForUser(uuid);
|
||||
}
|
||||
} else {
|
||||
console.error(`[SpotifyPolling] Unknown error for ${uuid}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _hasStateChanged(lastState: any, currentState: any): boolean {
|
||||
if (!currentState && !lastState) return false;
|
||||
if (!currentState || !lastState) return true;
|
||||
|
||||
return lastState.item?.id !== currentState.item?.id ||
|
||||
lastState.is_playing !== currentState.is_playing;
|
||||
}
|
||||
|
||||
private _pausePolling(uuid: string, durationMs: number): void {
|
||||
if (activePolls.has(uuid)) {
|
||||
clearInterval(activePolls.get(uuid)!);
|
||||
activePolls.delete(uuid);
|
||||
setTimeout(() => {
|
||||
console.log(`[SpotifyPolling] Resuming polling for ${uuid}.`);
|
||||
this.userService.getUserByUUID(uuid).then(user => {
|
||||
if (user) this.startPollingForUser(user);
|
||||
});
|
||||
}, durationMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from "axios";
|
||||
import {OAuthTokenResponse} from "../../interfaces/OAuthTokenResponse";
|
||||
import {OAuthTokenResponse} from "../interfaces/OAuthTokenResponse";
|
||||
|
||||
const url = "https://accounts.spotify.com/api/token";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventEmitter } from 'events';
|
||||
export const appEventBus = new EventEmitter();
|
||||
|
||||
export const USER_UPDATED_EVENT = 'user:updated';
|
||||
export const USER_UPDATED_EVENT = 'user:updated';
|
||||
export const SPOTIFY_STATE_UPDATED_EVENT = 'spotify:updated';
|
||||
@@ -1,13 +0,0 @@
|
||||
import {ExtendedWebSocket} from "../../../interfaces/extendedWebsocket";
|
||||
import {CustomWebsocketEvent} from "./customWebsocketEvent";
|
||||
import {UserService} from "../../../db/services/db/UserService";
|
||||
|
||||
export abstract class CustomWebsocketEventUserService<T = any> extends CustomWebsocketEvent<T> {
|
||||
protected readonly userService: UserService;
|
||||
|
||||
public constructor(ws: ExtendedWebSocket, userService: UserService) {
|
||||
super(ws);
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,88 +1,21 @@
|
||||
import {CustomWebsocketEvent} from "./customWebsocketEvent";
|
||||
import { SpotifyPollingService } from "../../../services/spotifyPollingService";
|
||||
import {WebsocketEventType} from "./websocketEventType";
|
||||
import {SpotifyTokenService} from "../../../db/services/spotifyTokenService";
|
||||
import {getCurrentlyPlaying} from "../../../db/services/spotifyApiService";
|
||||
import {CustomWebsocketEventUserService} from "./customWebsocketEventUserService";
|
||||
import {NoData} from "./NoData";
|
||||
import {UserService} from "../../../db/services/db/UserService";
|
||||
import {ExtendedWebSocket} from "../../../interfaces/extendedWebsocket";
|
||||
import {CustomWebsocketEvent} from "./customWebsocketEvent";
|
||||
|
||||
export const SpotifyAsyncUpdateEvent = "SPOTIFY_UPDATE";
|
||||
|
||||
export class GetSpotifyUpdatesEvent extends CustomWebsocketEvent<NoData> {
|
||||
event = WebsocketEventType.GET_SPOTIFY_UPDATE;
|
||||
|
||||
event = WebsocketEventType.GET_SPOTIFY_UPDATES;
|
||||
|
||||
handler = async () => {
|
||||
console.log("Starting Spotify updates");
|
||||
this.ws.emit(WebsocketEventType.GET_SINGLE_SPOTIFY_UPDATE, {});
|
||||
|
||||
if (this.ws.asyncUpdates.has(SpotifyAsyncUpdateEvent)) {
|
||||
console.log("Spotify updates already running");
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.asyncUpdates.set(SpotifyAsyncUpdateEvent, setInterval(() => {
|
||||
this.ws.emit(WebsocketEventType.GET_SINGLE_SPOTIFY_UPDATE, {});
|
||||
}, 1000));
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export class GetSingleSpotifyUpdateEvent extends CustomWebsocketEventUserService<NoData> {
|
||||
|
||||
private readonly spotifyTokenService: SpotifyTokenService;
|
||||
|
||||
event = WebsocketEventType.GET_SINGLE_SPOTIFY_UPDATE;
|
||||
|
||||
constructor(ws: ExtendedWebSocket, userService: UserService, spotifyTokenService: SpotifyTokenService) {
|
||||
super(ws, userService);
|
||||
this.spotifyTokenService = spotifyTokenService;
|
||||
constructor(ws: ExtendedWebSocket, private spotifyPollingService: SpotifyPollingService) {
|
||||
super(ws);
|
||||
}
|
||||
|
||||
handler = async () => {
|
||||
console.log("Getting single Spotify update event");
|
||||
await this.spotifyUpdates();
|
||||
console.log("Client requests Spotify updates. Starting polling.");
|
||||
if (this.ws.user) {
|
||||
this.spotifyPollingService.startPollingForUser(this.ws.user);
|
||||
}
|
||||
}
|
||||
|
||||
private async spotifyUpdates() {
|
||||
console.log("Checking Spotify")
|
||||
const user = this.ws.user;
|
||||
const spotifyConfig = user.spotifyConfig;
|
||||
if (!spotifyConfig) {
|
||||
console.log("No Spotify config found");
|
||||
// stop the interval
|
||||
this.ws.emit(WebsocketEventType.STOP_SPOTIFY_UPDATES, {});
|
||||
return;
|
||||
}
|
||||
if (Date.now() > spotifyConfig.expirationDate.getTime()) {
|
||||
console.log("Token expired");
|
||||
|
||||
const token = await this.spotifyTokenService.refreshToken(spotifyConfig.refreshToken);
|
||||
const newSpotifyConfig = {
|
||||
// use old refresh token because you don't get a new one
|
||||
refreshToken: user.spotifyConfig!.refreshToken,
|
||||
accessToken: token.access_token,
|
||||
expirationDate: new Date(Date.now() + token.expires_in * 1000),
|
||||
scope: token.scope,
|
||||
};
|
||||
await this.userService.updateUserById(user.id, {spotifyConfig: newSpotifyConfig});
|
||||
this.ws.user.spotifyConfig = newSpotifyConfig;
|
||||
console.log("Token refreshed and database updated");
|
||||
}
|
||||
const musicData = await getCurrentlyPlaying(this.ws.user.spotifyConfig!.accessToken);
|
||||
if (!musicData) {
|
||||
console.log("No music data found, maybe error from spotify, skipping this update");
|
||||
return;
|
||||
}
|
||||
console.log("Sending Spotify update");
|
||||
console.log(musicData);
|
||||
|
||||
this.ws.send(JSON.stringify({
|
||||
type: "SPOTIFY_UPDATE",
|
||||
payload: musicData,
|
||||
}), {binary: false});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {CustomWebsocketEvent} from "./customWebsocketEvent";
|
||||
import {WebsocketEventType} from "./websocketEventType";
|
||||
import {getCurrentWeather} from "../../../db/services/owmApiService";
|
||||
import {NoData} from "./NoData";
|
||||
import {getCurrentWeather} from "../../../services/owmApiService";
|
||||
|
||||
export const WeatherAsyncUpdateEvent = "WEATHER_UPDATE";
|
||||
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import {CustomWebsocketEvent} from "./customWebsocketEvent";
|
||||
import {WebsocketEventType} from "./websocketEventType";
|
||||
import {SpotifyAsyncUpdateEvent} from "./getSpotifyUpdatesEvent";
|
||||
import {NoData} from "./NoData";
|
||||
import {SpotifyPollingService} from "../../../services/spotifyPollingService";
|
||||
import {ExtendedWebSocket} from "../../../interfaces/extendedWebsocket";
|
||||
|
||||
export class StopSpotifyUpdatesEvent extends CustomWebsocketEvent<NoData> {
|
||||
|
||||
event = WebsocketEventType.STOP_SPOTIFY_UPDATES;
|
||||
|
||||
private readonly spotifyPollingService: SpotifyPollingService;
|
||||
|
||||
constructor(ws: ExtendedWebSocket, spotifyPollingService: SpotifyPollingService) {
|
||||
super(ws);
|
||||
this.spotifyPollingService = spotifyPollingService;
|
||||
}
|
||||
|
||||
handler = async () => {
|
||||
if (this.ws.asyncUpdates.has(SpotifyAsyncUpdateEvent)) {
|
||||
clearInterval(this.ws.asyncUpdates.get(SpotifyAsyncUpdateEvent));
|
||||
this.ws.asyncUpdates.delete(SpotifyAsyncUpdateEvent);
|
||||
console.log("Spotify updates stopped");
|
||||
console.log("Client requests to stop Spotify updates. Stopping polling.");
|
||||
|
||||
const uuid = this.ws.payload?.uuid;
|
||||
|
||||
if (uuid) {
|
||||
this.spotifyPollingService.stopPollingForUser(uuid);
|
||||
} else {
|
||||
console.warn("Could not stop Spotify polling: No UUID found on WebSocket payload.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
export enum WebsocketEventType {
|
||||
GET_SETTINGS = "GET_SETTINGS",
|
||||
GET_STATE = "GET_STATE",
|
||||
GET_SINGLE_SPOTIFY_UPDATE = "GET_SINGLE_SPOTIFY_UPDATE",
|
||||
GET_SPOTIFY_UPDATES = "GET_SPOTIFY_UPDATES",
|
||||
GET_SPOTIFY_UPDATE = "GET_SPOTIFY_UPDATES",
|
||||
GET_SINGLE_WEATHER_UPDATE = "GET_SINGLE_WEATHER_UPDATE",
|
||||
GET_WEATHER_UPDATES = "GET_WEATHER_UPDATES",
|
||||
STOP_SPOTIFY_UPDATES = "STOP_SPOTIFY_UPDATES",
|
||||
STOP_WEATHER_UPDATES = "STOP_WEATHER_UPDATES",
|
||||
ERROR = "ERROR",
|
||||
UPDATE_USER = "UPDATE_USER",
|
||||
UPDATE_USER_SINGLE = "UPDATE_USER_SINGLE",
|
||||
STOP_UPDATE_USER = "STOP_UPDATE_USER",
|
||||
|
||||
}
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import {ExtendedWebSocket} from "../../../interfaces/extendedWebsocket";
|
||||
import {GetSettingsEvent} from "./getSettingsEvent";
|
||||
import {ErrorEvent} from "./errorEvent";
|
||||
import {GetSingleSpotifyUpdateEvent, GetSpotifyUpdatesEvent} from "./getSpotifyUpdatesEvent";
|
||||
import {GetSpotifyUpdatesEvent} from "./getSpotifyUpdatesEvent";
|
||||
import {GetStateEvent} from "./getStateEvent";
|
||||
import {GetSingleWeatherUpdateEvent, GetWeatherUpdatesEvent} from "./getWeatherUpdatesEvent";
|
||||
import {StopSpotifyUpdatesEvent} from "./stopSpotifyUpdatesEvent";
|
||||
import {StopWeatherUpdatesEvent} from "./stopWeatherUpdatesEvent";
|
||||
import { UpdateUserSingleEvent} from "./updateUserEvent";
|
||||
import {CustomWebsocketEvent} from "./customWebsocketEvent";
|
||||
import {UserService} from "../../../db/services/db/UserService";
|
||||
import {SpotifyTokenService} from "../../../db/services/spotifyTokenService";
|
||||
import {SpotifyPollingService} from "../../../services/spotifyPollingService";
|
||||
|
||||
export function getEventListeners(ws: ExtendedWebSocket, userService: UserService, spotifyTokenService: SpotifyTokenService): CustomWebsocketEvent[] {
|
||||
export function getEventListeners(ws: ExtendedWebSocket, spotifyPollingService: SpotifyPollingService): CustomWebsocketEvent[] {
|
||||
return [
|
||||
new GetStateEvent(ws),
|
||||
new GetSettingsEvent(ws),
|
||||
new GetSingleSpotifyUpdateEvent(ws, userService, spotifyTokenService),
|
||||
new GetSpotifyUpdatesEvent(ws),
|
||||
new StopSpotifyUpdatesEvent(ws),
|
||||
new GetSpotifyUpdatesEvent(ws, spotifyPollingService),
|
||||
new StopSpotifyUpdatesEvent(ws, spotifyPollingService),
|
||||
new GetSingleWeatherUpdateEvent(ws),
|
||||
new GetWeatherUpdatesEvent(ws),
|
||||
new StopWeatherUpdatesEvent(ws),
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import {ExtendedWebSocket} from "../../interfaces/extendedWebsocket";
|
||||
import {CustomWebsocketEvent} from "./websocketCustomEvents/customWebsocketEvent";
|
||||
import {UserService} from "../../db/services/db/UserService";
|
||||
import {getEventListeners} from "./websocketCustomEvents/websocketEventUtils";
|
||||
import {SpotifyTokenService} from "../../db/services/spotifyTokenService";
|
||||
import {SpotifyPollingService} from "../../services/spotifyPollingService";
|
||||
|
||||
export class WebsocketEventHandler {
|
||||
constructor(private webSocket: ExtendedWebSocket, private userService: UserService, private spotifyTokenService: SpotifyTokenService) {
|
||||
constructor(private webSocket: ExtendedWebSocket, private spotifyPollingService: SpotifyPollingService) {
|
||||
}
|
||||
|
||||
public enableErrorEvent() {
|
||||
@@ -46,7 +45,7 @@ export class WebsocketEventHandler {
|
||||
}
|
||||
|
||||
public registerCustomEvents() {
|
||||
const events = getEventListeners(this.webSocket, this.userService, this.spotifyTokenService);
|
||||
const events = getEventListeners(this.webSocket, this.spotifyPollingService);
|
||||
events.forEach(this.registerCustomEvent, this);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {ExtendedWebSocket} from "../../interfaces/extendedWebsocket";
|
||||
import {ExtendedIncomingMessage} from "../../interfaces/extendedIncomingMessage";
|
||||
import {Server as WebSocketServer} from "ws";
|
||||
import {heartbeat} from "./websocketServerHeartbeatInterval";
|
||||
import {UserService} from "../../db/services/db/UserService";
|
||||
import {UserService} from "../../services/db/UserService";
|
||||
|
||||
export class WebsocketServerEventHandler {
|
||||
private readonly heartbeat: () => void;
|
||||
|
||||
+29
-25
@@ -1,23 +1,23 @@
|
||||
import { Server } from "http";
|
||||
import { Server as WebSocketServer, WebSocket } from "ws";
|
||||
import { verifyClient } from "./utils/verifyClient";
|
||||
import { ExtendedWebSocket } from "./interfaces/extendedWebsocket";
|
||||
import { WebsocketServerEventHandler } from "./utils/websocket/websocketServerEventHandler";
|
||||
import { WebsocketEventHandler } from "./utils/websocket/websocketEventHandler";
|
||||
import { WebsocketEventType } from "./utils/websocket/websocketCustomEvents/websocketEventType";
|
||||
import { UserService } from "./db/services/db/UserService";
|
||||
import { SpotifyTokenService } from "./db/services/spotifyTokenService";
|
||||
import { appEventBus, USER_UPDATED_EVENT } from "./utils/eventBus";
|
||||
import { IUser } from "./db/models/user";
|
||||
import {Server} from "http";
|
||||
import {Server as WebSocketServer, WebSocket} from "ws";
|
||||
import {verifyClient} from "./utils/verifyClient";
|
||||
import {ExtendedWebSocket} from "./interfaces/extendedWebsocket";
|
||||
import {WebsocketServerEventHandler} from "./utils/websocket/websocketServerEventHandler";
|
||||
import {WebsocketEventHandler} from "./utils/websocket/websocketEventHandler";
|
||||
import {WebsocketEventType} from "./utils/websocket/websocketCustomEvents/websocketEventType";
|
||||
import {appEventBus, SPOTIFY_STATE_UPDATED_EVENT, USER_UPDATED_EVENT} from "./utils/eventBus";
|
||||
import {IUser} from "./db/models/user";
|
||||
import {SpotifyPollingService} from "./services/spotifyPollingService";
|
||||
import {UserService} from "./services/db/UserService";
|
||||
|
||||
export class ExtendedWebSocketServer {
|
||||
private readonly _wss: WebSocketServer;
|
||||
private readonly userService: UserService;
|
||||
private readonly spotifyTokenService: SpotifyTokenService;
|
||||
private readonly spotifyPollingService: SpotifyPollingService;
|
||||
|
||||
constructor(server: Server, userService: UserService, spotifyTokenService: SpotifyTokenService) {
|
||||
constructor(server: Server, userService: UserService, spotifyPollingService: SpotifyPollingService) {
|
||||
this.userService = userService;
|
||||
this.spotifyTokenService = spotifyTokenService;
|
||||
this.spotifyPollingService = spotifyPollingService;
|
||||
|
||||
this._wss = new WebSocketServer({
|
||||
server,
|
||||
@@ -31,7 +31,7 @@ export class ExtendedWebSocketServer {
|
||||
public broadcast(message: string): void {
|
||||
this.getConnectedClients().forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(message, { binary: false });
|
||||
client.send(message, {binary: false});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export class ExtendedWebSocketServer {
|
||||
public sendMessageToUser(uuid: string, message: string): void {
|
||||
const client = this._findClientByUUID(uuid);
|
||||
if (client && client.readyState === WebSocket.OPEN) {
|
||||
client.send(message, { binary: false });
|
||||
client.send(message, {binary: false});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export class ExtendedWebSocketServer {
|
||||
private _onNewClientReady(ws: ExtendedWebSocket): void {
|
||||
console.log("WebSocket client connected and authenticated");
|
||||
|
||||
const socketEventHandler = new WebsocketEventHandler(ws, this.userService, this.spotifyTokenService);
|
||||
const socketEventHandler = new WebsocketEventHandler(ws, this.spotifyPollingService);
|
||||
|
||||
socketEventHandler.enableErrorEvent();
|
||||
socketEventHandler.enablePongEvent();
|
||||
@@ -76,14 +76,6 @@ export class ExtendedWebSocketServer {
|
||||
// send initial state and settings
|
||||
ws.emit(WebsocketEventType.GET_SETTINGS, {});
|
||||
ws.emit(WebsocketEventType.GET_STATE, {});
|
||||
|
||||
const mode = ws.user.lastState?.global.mode;
|
||||
if (mode === "clock") {
|
||||
ws.emit(WebsocketEventType.GET_WEATHER_UPDATES, {});
|
||||
}
|
||||
if (mode === "music") {
|
||||
ws.emit(WebsocketEventType.GET_SPOTIFY_UPDATES, {});
|
||||
}
|
||||
}
|
||||
|
||||
private _listenForAppEvents(): void {
|
||||
@@ -95,6 +87,18 @@ export class ExtendedWebSocketServer {
|
||||
client.emit(WebsocketEventType.UPDATE_USER_SINGLE, user);
|
||||
}
|
||||
});
|
||||
|
||||
appEventBus.on(SPOTIFY_STATE_UPDATED_EVENT, ({uuid, state}) => {
|
||||
const client = this._findClientByUUID(uuid);
|
||||
console.log(`Received update for user ${uuid}`);
|
||||
if (client) {
|
||||
client.send(JSON.stringify({
|
||||
type: "SPOTIFY_UPDATE",
|
||||
payload: state,
|
||||
}), {binary: false});
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private _findClientByUUID(uuid: string): ExtendedWebSocket | undefined {
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import axios from "axios";
|
||||
import { getCurrentlyPlaying, CurrentlyPlaying } from "../../../src/db/services/spotifyApiService";
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedAxios = vi.mocked(axios, true);
|
||||
|
||||
describe("spotifyApiService", () => {
|
||||
let consoleSpy: any;
|
||||
let consoleErrorSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("getCurrentlyPlaying", () => {
|
||||
it("should return currently playing track data", async () => {
|
||||
const accessToken = "test-access-token";
|
||||
const mockCurrentlyPlaying: CurrentlyPlaying = {
|
||||
timestamp: 1640995200000,
|
||||
context: {
|
||||
type: "playlist",
|
||||
uri: "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M",
|
||||
},
|
||||
progress_ms: 45000,
|
||||
item: {
|
||||
name: "Test Song",
|
||||
artists: [
|
||||
{
|
||||
name: "Test Artist",
|
||||
uri: "spotify:artist:test123",
|
||||
},
|
||||
],
|
||||
album: {
|
||||
name: "Test Album",
|
||||
uri: "spotify:album:test456",
|
||||
},
|
||||
duration_ms: 180000,
|
||||
},
|
||||
is_playing: true,
|
||||
};
|
||||
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: mockCurrentlyPlaying,
|
||||
});
|
||||
|
||||
const result = await getCurrentlyPlaying(accessToken);
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
"https://api.spotify.com/v1/me/player/currently-playing",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
params: {
|
||||
additional_types: "episode",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockCurrentlyPlaying);
|
||||
});
|
||||
|
||||
it("should return null when nothing is playing (204 status)", async () => {
|
||||
const accessToken = "test-access-token";
|
||||
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
status: 204,
|
||||
data: null,
|
||||
});
|
||||
|
||||
const result = await getCurrentlyPlaying(accessToken);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Es wird gerade nichts abgespielt.");
|
||||
});
|
||||
|
||||
it("should handle track with minimal data", async () => {
|
||||
const accessToken = "test-access-token";
|
||||
const mockMinimalTrack: CurrentlyPlaying = {
|
||||
timestamp: 1640995200000,
|
||||
is_playing: false,
|
||||
};
|
||||
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: mockMinimalTrack,
|
||||
});
|
||||
|
||||
const result = await getCurrentlyPlaying(accessToken);
|
||||
|
||||
expect(result).toEqual(mockMinimalTrack);
|
||||
expect(result?.item).toBeUndefined();
|
||||
expect(result?.progress_ms).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle track with multiple artists", async () => {
|
||||
const accessToken = "test-access-token";
|
||||
const mockTrackWithMultipleArtists: CurrentlyPlaying = {
|
||||
timestamp: 1640995200000,
|
||||
item: {
|
||||
name: "Collaboration Song",
|
||||
artists: [
|
||||
{
|
||||
name: "Artist One",
|
||||
uri: "spotify:artist:artist1",
|
||||
},
|
||||
{
|
||||
name: "Artist Two",
|
||||
uri: "spotify:artist:artist2",
|
||||
},
|
||||
{
|
||||
name: "Artist Three",
|
||||
uri: "spotify:artist:artist3",
|
||||
},
|
||||
],
|
||||
album: {
|
||||
name: "Collaboration Album",
|
||||
uri: "spotify:album:collab123",
|
||||
},
|
||||
duration_ms: 240000,
|
||||
},
|
||||
is_playing: true,
|
||||
};
|
||||
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: mockTrackWithMultipleArtists,
|
||||
});
|
||||
|
||||
const result = await getCurrentlyPlaying(accessToken);
|
||||
|
||||
expect(result?.item?.artists).toHaveLength(3);
|
||||
expect(result?.item?.artists[0].name).toBe("Artist One");
|
||||
expect(result?.item?.artists[2].name).toBe("Artist Three");
|
||||
});
|
||||
|
||||
it("should handle episodes (podcasts)", async () => {
|
||||
const accessToken = "test-access-token";
|
||||
const mockEpisode: CurrentlyPlaying = {
|
||||
timestamp: 1640995200000,
|
||||
context: {
|
||||
type: "show",
|
||||
uri: "spotify:show:test123",
|
||||
},
|
||||
progress_ms: 600000,
|
||||
item: {
|
||||
name: "Test Podcast Episode",
|
||||
artists: [
|
||||
{
|
||||
name: "Podcast Host",
|
||||
uri: "spotify:artist:host123",
|
||||
},
|
||||
],
|
||||
album: {
|
||||
name: "Test Podcast Show",
|
||||
uri: "spotify:show:test123",
|
||||
},
|
||||
duration_ms: 3600000,
|
||||
},
|
||||
is_playing: true,
|
||||
};
|
||||
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: mockEpisode,
|
||||
});
|
||||
|
||||
const result = await getCurrentlyPlaying(accessToken);
|
||||
|
||||
expect(result).toEqual(mockEpisode);
|
||||
expect(result?.context?.type).toBe("show");
|
||||
});
|
||||
|
||||
it("should handle 401 unauthorized error", async () => {
|
||||
const accessToken = "invalid-token";
|
||||
const unauthorizedError = {
|
||||
response: {
|
||||
status: 401,
|
||||
data: {
|
||||
error: {
|
||||
status: 401,
|
||||
message: "Invalid access token",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockedAxios.get.mockRejectedValue(unauthorizedError);
|
||||
|
||||
const result = await getCurrentlyPlaying(accessToken);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Fehler bei der Anfrage:",
|
||||
401,
|
||||
unauthorizedError.response.data
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle 403 forbidden error (premium required)", async () => {
|
||||
const accessToken = "valid-but-non-premium-token";
|
||||
const forbiddenError = {
|
||||
response: {
|
||||
status: 403,
|
||||
data: {
|
||||
error: {
|
||||
status: 403,
|
||||
message: "Player command failed: Premium required",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockedAxios.get.mockRejectedValue(forbiddenError);
|
||||
|
||||
const result = await getCurrentlyPlaying(accessToken);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Fehler bei der Anfrage:",
|
||||
403,
|
||||
forbiddenError.response.data
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle 429 rate limit error", async () => {
|
||||
const accessToken = "test-access-token";
|
||||
const rateLimitError = {
|
||||
response: {
|
||||
status: 429,
|
||||
data: {
|
||||
error: {
|
||||
status: 429,
|
||||
message: "API rate limit exceeded",
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
"retry-after": "30",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockedAxios.get.mockRejectedValue(rateLimitError);
|
||||
|
||||
const result = await getCurrentlyPlaying(accessToken);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Fehler bei der Anfrage:",
|
||||
429,
|
||||
rateLimitError.response.data
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle network errors", async () => {
|
||||
const accessToken = "test-access-token";
|
||||
const networkError = {
|
||||
code: "ECONNREFUSED",
|
||||
message: "Network Error",
|
||||
};
|
||||
|
||||
mockedAxios.get.mockRejectedValue(networkError);
|
||||
|
||||
const result = await getCurrentlyPlaying(accessToken);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Fehler bei der Anfrage:",
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it("should include additional_types parameter for episodes", async () => {
|
||||
const accessToken = "test-access-token";
|
||||
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
status: 204,
|
||||
data: null,
|
||||
});
|
||||
|
||||
await getCurrentlyPlaying(accessToken);
|
||||
|
||||
const [, config] = mockedAxios.get.mock.calls[0];
|
||||
expect(config?.params?.additional_types).toBe("episode");
|
||||
});
|
||||
|
||||
it("should use correct Spotify API endpoint", async () => {
|
||||
const accessToken = "test-access-token";
|
||||
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
status: 204,
|
||||
data: null,
|
||||
});
|
||||
|
||||
await getCurrentlyPlaying(accessToken);
|
||||
|
||||
const [url] = mockedAxios.get.mock.calls[0];
|
||||
expect(url).toBe("https://api.spotify.com/v1/me/player/currently-playing");
|
||||
});
|
||||
|
||||
it("should format authorization header correctly", async () => {
|
||||
const accessToken = "test-access-token-123";
|
||||
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
status: 204,
|
||||
data: null,
|
||||
});
|
||||
|
||||
await getCurrentlyPlaying(accessToken);
|
||||
|
||||
const [, config] = mockedAxios.get.mock.calls[0];
|
||||
expect(config?.headers?.Authorization).toBe(`Bearer ${accessToken}`);
|
||||
});
|
||||
|
||||
it("should handle empty access token", async () => {
|
||||
const accessToken = "";
|
||||
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
status: 204,
|
||||
data: null,
|
||||
});
|
||||
|
||||
await getCurrentlyPlaying(accessToken);
|
||||
|
||||
const [, config] = mockedAxios.get.mock.calls[0];
|
||||
expect(config?.headers?.Authorization).toBe("Bearer ");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import express, { Router } from "express";
|
||||
import { vi, type Mocked } from "vitest";
|
||||
import { UserService } from "../../src/db/services/db/UserService";
|
||||
import { UserService } from "../../src/services/db/UserService";
|
||||
import { PasswordUtils } from "../../src/utils/passwordUtils";
|
||||
|
||||
export const defaultMockPayload = {
|
||||
@@ -56,6 +56,7 @@ export const createMockUserService = () => ({
|
||||
existsUserByName: vi.fn(),
|
||||
createUser: vi.fn(),
|
||||
getUserAuthByName: vi.fn(),
|
||||
updateUserByUUID: vi.fn(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -114,4 +115,8 @@ export const createMockJwtAuthenticator = () => ({
|
||||
export const createMockSpotifyTokenService = () => ({
|
||||
refreshToken: vi.fn(),
|
||||
generateToken: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
export const createMockSpotifyApiService = () => ({
|
||||
getCurrentlyPlaying: vi.fn(),
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import request from "supertest";
|
||||
import {RestUser} from "../../src/rest/restUser";
|
||||
import {createMockUserService, setupTestEnvironment, type TestEnvironment} from "../helpers/testSetup";
|
||||
|
||||
vi.mock("../../src/db/services/db/UserService", () => ({
|
||||
vi.mock("../../src/services/db/UserService", () => ({
|
||||
UserService: {
|
||||
create: vi.fn()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import request from "supertest";
|
||||
import express from "express";
|
||||
|
||||
import { SpotifyTokenGenerator } from "../../src/rest/spotifyTokenGenerator";
|
||||
import { SpotifyTokenService } from "../../src/db/services/spotifyTokenService";
|
||||
import { createTestApp, createMockSpotifyTokenService } from "../helpers/testSetup";
|
||||
|
||||
vi.mock("../../src/db/services/spotifyTokenService");
|
||||
|
||||
@@ -5,14 +5,14 @@ import { Router, type Request, type Response, type NextFunction } from "express"
|
||||
import type { Express } from "express";
|
||||
import { authLimiter } from "../src/rest/middleware/rateLimit";
|
||||
|
||||
vi.mock("../src/db/services/db/database.service", () => ({
|
||||
vi.mock("../src/services/db/database.service", () => ({
|
||||
connectToDatabase: vi.fn().mockResolvedValue(undefined),
|
||||
disconnectFromDatabase: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("../src/db/services/db/UserService", () => ({
|
||||
vi.mock("../src/services/db/UserService", () => ({
|
||||
UserService: { create: vi.fn().mockResolvedValue({}) },
|
||||
}));
|
||||
vi.mock("../src/db/services/spotifyTokenService", () => ({ SpotifyTokenService: vi.fn() }));
|
||||
vi.mock("../src/services/spotifyTokenService", () => ({ SpotifyTokenService: vi.fn() }));
|
||||
|
||||
vi.mock("../src/websocket", () => ({
|
||||
ExtendedWebSocketServer: vi.fn().mockImplementation(() => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { UserService } from "../../../../src/db/services/db/UserService";
|
||||
import {UserModel} from "../../../../src/db/models/user";
|
||||
import { connectToDatabase } from "../../../../src/db/services/db/database.service";
|
||||
import {UserModel} from "../../../src/db/models/user";
|
||||
import {UserService} from "../../../src/services/db/UserService";
|
||||
import {connectToDatabase} from "../../../src/services/db/database.service";
|
||||
|
||||
vi.mock("../../../../src/db/services/db/database.service", () => ({
|
||||
vi.mock("../../../../src/services/db/database.service", () => ({
|
||||
connectToDatabase: vi.fn(),
|
||||
}));
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import mongoose from "mongoose";
|
||||
|
||||
const MODULE_PATH = "../../../../src/db/services/db/database.service";
|
||||
const MODULE_PATH = "../../../../src/services/db/database.service";
|
||||
|
||||
type SpyInstance<T extends (...args: any) => any> = ReturnType<typeof vi.spyOn<any, Parameters<T>[0]>>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {describe, it, expect, vi, beforeEach} from "vitest";
|
||||
import OpenWeatherAPI from "openweather-api-node";
|
||||
import {getCurrentWeather} from "../../../src/db/services/owmApiService";
|
||||
import {getCurrentWeather} from "../../src/services/owmApiService";
|
||||
|
||||
vi.mock("openweather-api-node", () => {
|
||||
return {
|
||||
@@ -0,0 +1,334 @@
|
||||
import {describe, it, expect, vi, beforeEach} from "vitest";
|
||||
import axios from "axios";
|
||||
import {CurrentlyPlaying} from "../../src/interfaces/CurrentlyPlaying";
|
||||
import {SpotifyApiService} from "../../src/services/spotifyApiService";
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
isAxiosError: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedAxios = vi.mocked(axios, true);
|
||||
|
||||
describe("spotifyApiService", () => {
|
||||
let consoleErrorSpy: any;
|
||||
|
||||
let spotifyApiService: SpotifyApiService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
|
||||
});
|
||||
spotifyApiService = new SpotifyApiService();
|
||||
});
|
||||
|
||||
describe("spotifyApiService.getCurrentlyPlaying", () => {
|
||||
it("should return currently playing track data", async () => {
|
||||
const accessToken = "test-access-token";
|
||||
const mockCurrentlyPlaying: CurrentlyPlaying = {
|
||||
timestamp: 1640995200000,
|
||||
context: {
|
||||
type: "playlist",
|
||||
uri: "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M",
|
||||
},
|
||||
progress_ms: 45000,
|
||||
item: {
|
||||
name: "Test Song",
|
||||
artists: [
|
||||
{
|
||||
name: "Test Artist",
|
||||
uri: "spotify:artist:test123",
|
||||
},
|
||||
],
|
||||
album: {
|
||||
name: "Test Album",
|
||||
uri: "spotify:album:test456",
|
||||
},
|
||||
duration_ms: 180000,
|
||||
},
|
||||
is_playing: true,
|
||||
};
|
||||
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: mockCurrentlyPlaying,
|
||||
});
|
||||
|
||||
const result = await spotifyApiService.getCurrentlyPlaying(accessToken);
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
"https://api.spotify.com/v1/me/player/currently-playing",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
params: {
|
||||
additional_types: "episode",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockCurrentlyPlaying);
|
||||
});
|
||||
|
||||
it("should return null when nothing is playing (204 status)", async () => {
|
||||
const accessToken = "test-access-token";
|
||||
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
status: 204,
|
||||
data: null,
|
||||
});
|
||||
|
||||
const result = await spotifyApiService.getCurrentlyPlaying(accessToken);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle track with minimal data", async () => {
|
||||
const accessToken = "test-access-token";
|
||||
const mockMinimalTrack: CurrentlyPlaying = {
|
||||
timestamp: 1640995200000,
|
||||
is_playing: false,
|
||||
};
|
||||
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: mockMinimalTrack,
|
||||
});
|
||||
|
||||
const result = await spotifyApiService.getCurrentlyPlaying(accessToken);
|
||||
|
||||
expect(result).toEqual(mockMinimalTrack);
|
||||
expect(result?.item).toBeUndefined();
|
||||
expect(result?.progress_ms).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle track with multiple artists", async () => {
|
||||
const accessToken = "test-access-token";
|
||||
const mockTrackWithMultipleArtists: CurrentlyPlaying = {
|
||||
timestamp: 1640995200000,
|
||||
item: {
|
||||
name: "Collaboration Song",
|
||||
artists: [
|
||||
{
|
||||
name: "Artist One",
|
||||
uri: "spotify:artist:artist1",
|
||||
},
|
||||
{
|
||||
name: "Artist Two",
|
||||
uri: "spotify:artist:artist2",
|
||||
},
|
||||
{
|
||||
name: "Artist Three",
|
||||
uri: "spotify:artist:artist3",
|
||||
},
|
||||
],
|
||||
album: {
|
||||
name: "Collaboration Album",
|
||||
uri: "spotify:album:collab123",
|
||||
},
|
||||
duration_ms: 240000,
|
||||
},
|
||||
is_playing: true,
|
||||
};
|
||||
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: mockTrackWithMultipleArtists,
|
||||
});
|
||||
|
||||
const result = await spotifyApiService.getCurrentlyPlaying(accessToken);
|
||||
|
||||
expect(result?.item?.artists).toHaveLength(3);
|
||||
expect(result?.item?.artists[0].name).toBe("Artist One");
|
||||
expect(result?.item?.artists[2].name).toBe("Artist Three");
|
||||
});
|
||||
|
||||
it("should handle episodes (podcasts)", async () => {
|
||||
const accessToken = "test-access-token";
|
||||
const mockEpisode: CurrentlyPlaying = {
|
||||
timestamp: 1640995200000,
|
||||
context: {
|
||||
type: "show",
|
||||
uri: "spotify:show:test123",
|
||||
},
|
||||
progress_ms: 600000,
|
||||
item: {
|
||||
name: "Test Podcast Episode",
|
||||
artists: [
|
||||
{
|
||||
name: "Podcast Host",
|
||||
uri: "spotify:artist:host123",
|
||||
},
|
||||
],
|
||||
album: {
|
||||
name: "Test Podcast Show",
|
||||
uri: "spotify:show:test123",
|
||||
},
|
||||
duration_ms: 3600000,
|
||||
},
|
||||
is_playing: true,
|
||||
};
|
||||
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: mockEpisode,
|
||||
});
|
||||
|
||||
const result = await spotifyApiService.getCurrentlyPlaying(accessToken);
|
||||
|
||||
expect(result).toEqual(mockEpisode);
|
||||
expect(result?.context?.type).toBe("show");
|
||||
});
|
||||
|
||||
it("should handle 401 unauthorized error", async () => {
|
||||
const accessToken = "invalid-token";
|
||||
const errorData = { error: { status: 401, message: "Invalid access token" } };
|
||||
const unauthorizedError = new Error("Request failed with status code 401");
|
||||
Object.assign(unauthorizedError, {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 401,
|
||||
data: errorData,
|
||||
}
|
||||
});
|
||||
|
||||
mockedAxios.get.mockRejectedValue(unauthorizedError);
|
||||
mockedAxios.isAxiosError.mockReturnValue(true);
|
||||
|
||||
|
||||
await expect(spotifyApiService.getCurrentlyPlaying(accessToken))
|
||||
.rejects.toThrow(unauthorizedError);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Spotify API Error:",
|
||||
401,
|
||||
errorData
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle 403 forbidden error (premium required)", async () => {
|
||||
const accessToken = "valid-but-non-premium-token";
|
||||
const errorData = { error: { status: 403, message: "Player command failed: Premium required" } };
|
||||
const forbiddenError = new Error("Request failed with status code 403");
|
||||
Object.assign(forbiddenError, {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 403,
|
||||
data: errorData,
|
||||
}
|
||||
});
|
||||
|
||||
mockedAxios.get.mockRejectedValue(forbiddenError);
|
||||
mockedAxios.isAxiosError.mockReturnValue(true)
|
||||
|
||||
await expect(spotifyApiService.getCurrentlyPlaying(accessToken))
|
||||
.rejects.toThrow(forbiddenError);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Spotify API Error:",
|
||||
403,
|
||||
errorData
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle 429 rate limit error", async () => {
|
||||
const accessToken = "test-access-token";
|
||||
const errorData = { error: { status: 429, message: "API rate limit exceeded" } };
|
||||
const rateLimitError = new Error("Request failed with status code 429");
|
||||
Object.assign(rateLimitError, {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 429,
|
||||
data: errorData,
|
||||
headers: {
|
||||
"retry-after": "30",
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
mockedAxios.get.mockRejectedValue(rateLimitError);
|
||||
mockedAxios.isAxiosError.mockReturnValue(true)
|
||||
|
||||
await expect(spotifyApiService.getCurrentlyPlaying(accessToken))
|
||||
.rejects.toThrow(rateLimitError);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Spotify API Error:",
|
||||
429,
|
||||
errorData
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw and NOT log a specific message for generic network errors", async () => {
|
||||
const accessToken = "test-access-token";
|
||||
const networkError = new Error("Network Error");
|
||||
|
||||
mockedAxios.isAxiosError.mockReturnValue(false);
|
||||
mockedAxios.get.mockRejectedValue(networkError);
|
||||
|
||||
await expect(spotifyApiService.getCurrentlyPlaying(accessToken))
|
||||
.rejects.toThrow("Network Error");
|
||||
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should include additional_types parameter for episodes", async () => {
|
||||
const accessToken = "test-access-token";
|
||||
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
status: 204,
|
||||
data: null,
|
||||
});
|
||||
|
||||
await spotifyApiService.getCurrentlyPlaying(accessToken);
|
||||
|
||||
const [, config] = mockedAxios.get.mock.calls[0];
|
||||
expect(config?.params?.additional_types).toBe("episode");
|
||||
});
|
||||
|
||||
it("should use correct Spotify API endpoint", async () => {
|
||||
const accessToken = "test-access-token";
|
||||
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
status: 204,
|
||||
data: null,
|
||||
});
|
||||
|
||||
await spotifyApiService.getCurrentlyPlaying(accessToken);
|
||||
|
||||
const [url] = mockedAxios.get.mock.calls[0];
|
||||
expect(url).toBe("https://api.spotify.com/v1/me/player/currently-playing");
|
||||
});
|
||||
|
||||
it("should format authorization header correctly", async () => {
|
||||
const accessToken = "test-access-token-123";
|
||||
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
status: 204,
|
||||
data: null,
|
||||
});
|
||||
|
||||
await spotifyApiService.getCurrentlyPlaying(accessToken);
|
||||
|
||||
const [, config] = mockedAxios.get.mock.calls[0];
|
||||
expect(config?.headers?.Authorization).toBe(`Bearer ${accessToken}`);
|
||||
});
|
||||
|
||||
it("should handle empty access token", async () => {
|
||||
const accessToken = "";
|
||||
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
status: 204,
|
||||
data: null,
|
||||
});
|
||||
|
||||
await spotifyApiService.getCurrentlyPlaying(accessToken);
|
||||
|
||||
const [, config] = mockedAxios.get.mock.calls[0];
|
||||
expect(config?.headers?.Authorization).toBe("Bearer ");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import {describe, it, expect, vi, beforeEach, afterEach, Mocked} from "vitest";
|
||||
import { AxiosError } from "axios";
|
||||
import { UserService } from "../../src/services/db/UserService";
|
||||
import { SpotifyApiService } from "../../src/services/spotifyApiService";
|
||||
import { SpotifyTokenService } from "../../src/services/spotifyTokenService";
|
||||
import { appEventBus, SPOTIFY_STATE_UPDATED_EVENT } from "../../src/utils/eventBus";
|
||||
import { SpotifyPollingService } from "../../src/services/spotifyPollingService";
|
||||
import { IUser } from "../../src/db/models/user";
|
||||
import { createMockSpotifyApiService, createMockSpotifyTokenService, createMockUserService } from "../helpers/testSetup";
|
||||
|
||||
vi.mock("../../src/services/db/UserService");
|
||||
vi.mock("../../src/services/spotifyApiService");
|
||||
vi.mock("../../src/services/spotifyTokenService");
|
||||
vi.mock("../../src/utils/eventBus", () => ({
|
||||
appEventBus: { emit: vi.fn() },
|
||||
SPOTIFY_STATE_UPDATED_EVENT: 'spotify:state-updated',
|
||||
}));
|
||||
|
||||
describe("SpotifyPollingService", () => {
|
||||
let mockedUserService: Mocked<UserService>;
|
||||
let mockedApiService: Mocked<SpotifyApiService>;
|
||||
let mockedTokenService: Mocked<SpotifyTokenService>;
|
||||
let mockedAppEventBus: Mocked<typeof appEventBus>;
|
||||
|
||||
let pollingService: SpotifyPollingService;
|
||||
|
||||
const mockUser: IUser = {
|
||||
uuid: "user-123",
|
||||
spotifyConfig: {
|
||||
accessToken: "valid-access-token",
|
||||
refreshToken: "valid-refresh-token",
|
||||
expirationDate: new Date(Date.now() + 3600 * 1000),
|
||||
},
|
||||
} as any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Recreate mocks
|
||||
mockedUserService = createMockUserService();
|
||||
mockedApiService = createMockSpotifyApiService() as any;
|
||||
mockedTokenService = createMockSpotifyTokenService() as any;
|
||||
mockedAppEventBus = appEventBus as Mocked<typeof appEventBus>;
|
||||
|
||||
const { SpotifyPollingService: FreshSpotifyPollingService } = await import('../../src/services/spotifyPollingService');
|
||||
|
||||
pollingService = new FreshSpotifyPollingService(
|
||||
mockedUserService,
|
||||
mockedApiService,
|
||||
mockedTokenService
|
||||
);
|
||||
|
||||
mockedUserService.getUserByUUID.mockResolvedValue(mockUser);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("startPollingForUser", () => {
|
||||
it("should immediately poll and then periodically every 3 seconds", async () => {
|
||||
mockedApiService.getCurrentlyPlaying.mockResolvedValue({ item: { id: "song-a" }, is_playing: true } as any);
|
||||
|
||||
pollingService.startPollingForUser(mockUser);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockedApiService.getCurrentlyPlaying).toHaveBeenCalledOnce();
|
||||
expect(mockedApiService.getCurrentlyPlaying).toHaveBeenCalledWith(mockUser.spotifyConfig!.accessToken);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
expect(mockedApiService.getCurrentlyPlaying).toHaveBeenCalledTimes(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
expect(mockedApiService.getCurrentlyPlaying).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("should not start a new polling interval if one is already running for the user", async () => {
|
||||
pollingService.startPollingForUser(mockUser);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
expect(mockedApiService.getCurrentlyPlaying).toHaveBeenCalledTimes(1);
|
||||
|
||||
pollingService.startPollingForUser(mockUser);
|
||||
expect(vi.getTimerCount()).toBe(1); // Still only one timer
|
||||
expect(mockedApiService.getCurrentlyPlaying).toHaveBeenCalledTimes(1); // No new immediate poll
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopPollingForUser", () => {
|
||||
it("should clear the active interval for the user", () => {
|
||||
pollingService.startPollingForUser(mockUser);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
pollingService.stopPollingForUser(mockUser.uuid);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Polling Logic and Event Emission", () => {
|
||||
it("should emit a state update event when the song changes", async () => {
|
||||
const initialState = { item: { id: "song-a" }, is_playing: true };
|
||||
const nextState = { item: { id: "song-b" }, is_playing: true };
|
||||
|
||||
mockedApiService.getCurrentlyPlaying
|
||||
.mockResolvedValueOnce(initialState as any)
|
||||
.mockResolvedValueOnce(nextState as any);
|
||||
|
||||
pollingService.startPollingForUser(mockUser);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(mockedAppEventBus.emit).toHaveBeenCalledWith(SPOTIFY_STATE_UPDATED_EVENT, { uuid: mockUser.uuid, state: initialState });
|
||||
expect(mockedAppEventBus.emit).toHaveBeenCalledTimes(1);
|
||||
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
expect(mockedAppEventBus.emit).toHaveBeenCalledWith(SPOTIFY_STATE_UPDATED_EVENT, { uuid: mockUser.uuid, state: nextState });
|
||||
|
||||
expect(mockedAppEventBus.emit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should NOT emit a state update event if the state is unchanged", async () => {
|
||||
const state = { item: { id: "song-a" }, is_playing: true };
|
||||
mockedApiService.getCurrentlyPlaying.mockResolvedValue(state as any);
|
||||
|
||||
pollingService.startPollingForUser(mockUser);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
|
||||
expect(mockedAppEventBus.emit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Token Refresh and Error Handling", () => {
|
||||
it("should refresh the token if it is expired and then call the API with the new token", async () => {
|
||||
const expiredUser = {
|
||||
...mockUser,
|
||||
spotifyConfig: { ...mockUser.spotifyConfig!, expirationDate: new Date(Date.now() - 1000) }
|
||||
};
|
||||
mockedUserService.getUserByUUID.mockResolvedValue(expiredUser as any);
|
||||
|
||||
const refreshedToken = {access_token: "new-refreshed-token", expires_in: 3600, scope: "some-scope"} as any;
|
||||
mockedTokenService.refreshToken.mockResolvedValue(refreshedToken);
|
||||
|
||||
mockedUserService.updateUserByUUID.mockImplementation(async (uuid, updates) => ({
|
||||
...expiredUser, ...updates
|
||||
} as any));
|
||||
|
||||
pollingService.startPollingForUser(expiredUser as IUser);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockedTokenService.refreshToken).toHaveBeenCalledWith(expiredUser.spotifyConfig.refreshToken);
|
||||
expect(mockedApiService.getCurrentlyPlaying).toHaveBeenCalledWith(refreshedToken.access_token);
|
||||
});
|
||||
|
||||
it("should stop polling if a 401 Unauthorized error occurs", async () => {
|
||||
const error = new AxiosError("Unauthorized");
|
||||
(error as any).response = { status: 401 };
|
||||
mockedApiService.getCurrentlyPlaying.mockRejectedValue(error);
|
||||
|
||||
pollingService.startPollingForUser(mockUser);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("should pause and automatically resume polling after a 429 Rate Limit error", async () => {
|
||||
const error = new AxiosError("Rate Limit");
|
||||
(error as any).response = { status: 429, headers: { "retry-after": "5" } };
|
||||
|
||||
mockedApiService.getCurrentlyPlaying
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockResolvedValue({ item: { id: "song-a" }, is_playing: true } as any);
|
||||
|
||||
pollingService.startPollingForUser(mockUser);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockedApiService.getCurrentlyPlaying).toHaveBeenCalledTimes(1);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
expect(mockedApiService.getCurrentlyPlaying).toHaveBeenCalledTimes(2);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import axios from "axios";
|
||||
import type { SpotifyTokenService as SpotifyTokenServiceType } from "../../../src/db/services/spotifyTokenService";
|
||||
import type { OAuthTokenResponse } from "../../../src/interfaces/OAuthTokenResponse";
|
||||
import type { OAuthTokenResponse } from "../../src/interfaces/OAuthTokenResponse";
|
||||
import {SpotifyTokenService} from "../../src/services/spotifyTokenService";
|
||||
|
||||
vi.mock("axios");
|
||||
const mockedAxiosPost = vi.mocked(axios.post);
|
||||
@@ -14,10 +14,10 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("SpotifyTokenService - Successful Initialization", () => {
|
||||
let spotifyTokenService: SpotifyTokenServiceType;
|
||||
let spotifyTokenService: SpotifyTokenService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const { SpotifyTokenService } = await import("../../../src/db/services/spotifyTokenService");
|
||||
const { SpotifyTokenService } = await import("../../src/services/spotifyTokenService");
|
||||
spotifyTokenService = new SpotifyTokenService("test-client-id","test-client-secret");
|
||||
});
|
||||
|
||||
@@ -1,101 +1,55 @@
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from "vitest";
|
||||
import { getEventListeners } from "../../../../src/utils/websocket/websocketCustomEvents/websocketEventUtils";
|
||||
import { WebsocketEventType } from "../../../../src/utils/websocket/websocketCustomEvents/websocketEventType";
|
||||
import { CustomWebsocketEvent } from "../../../../src/utils/websocket/websocketCustomEvents/customWebsocketEvent";
|
||||
import type { UserService } from "../../../../src/db/services/db/UserService";
|
||||
import {
|
||||
CustomWebsocketEventUserService
|
||||
} from "../../../../src/utils/websocket/websocketCustomEvents/customWebsocketEventUserService";
|
||||
import {createMockSpotifyTokenService} from "../../../helpers/testSetup";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { ExtendedWebSocket } from "../../../../src/interfaces/extendedWebsocket";
|
||||
import { GetStateEvent } from "../../../../src/utils/websocket/websocketCustomEvents/getStateEvent";
|
||||
import { GetSettingsEvent } from "../../../../src/utils/websocket/websocketCustomEvents/getSettingsEvent";
|
||||
|
||||
type MockWs = {
|
||||
user: {
|
||||
timezone: string;
|
||||
lastState: { global: { mode: string; brightness: number } };
|
||||
};
|
||||
send: Mocked<(data: any, options: { binary: boolean }) => void>;
|
||||
on: Mocked<(event: string, listener: (...args: any[]) => void) => void>;
|
||||
emit: Mocked<(event: string, ...args: any[]) => void>;
|
||||
const createMockWebSocket = (userPayload: any = {}): ExtendedWebSocket => {
|
||||
return {
|
||||
send: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
user: {
|
||||
timezone: "Europe/Berlin",
|
||||
lastState: { global: { mode: "idle", brightness: 42 } },
|
||||
...userPayload,
|
||||
},
|
||||
payload: { uuid: "test-uuid-123" },
|
||||
} as unknown as ExtendedWebSocket;
|
||||
};
|
||||
|
||||
type MockUserService = Mocked<UserService>;
|
||||
|
||||
describe("WebSocket Custom Event Handlers", () => {
|
||||
|
||||
|
||||
describe("websocketEventUtils.getEventListeners", () => {
|
||||
let mockWs: MockWs;
|
||||
let mockUserService: MockUserService;
|
||||
let listeners: CustomWebsocketEvent[];
|
||||
describe("GetStateEvent", () => {
|
||||
it("should send the user's lastState when its handler is called", async () => {
|
||||
const mockLastState = { global: { mode: "music", brightness: 100 } };
|
||||
const mockWs = createMockWebSocket({ lastState: mockLastState });
|
||||
|
||||
beforeEach(() => {
|
||||
mockWs = {
|
||||
user: {
|
||||
timezone: "Europe/Berlin",
|
||||
lastState: { global: { mode: "idle", brightness: 42 } },
|
||||
},
|
||||
send: vi.fn(),
|
||||
on: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
};
|
||||
|
||||
mockUserService = {
|
||||
getUserByUUID: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
} as any;
|
||||
|
||||
|
||||
|
||||
listeners = getEventListeners(mockWs as any, mockUserService, createMockSpotifyTokenService() as any);
|
||||
});
|
||||
|
||||
it("should return an array of event listener objects", () => {
|
||||
expect(Array.isArray(listeners)).toBe(true);
|
||||
expect(listeners.length).toBeGreaterThan(0);
|
||||
|
||||
for (const listener of listeners) {
|
||||
expect(listener).toHaveProperty("event");
|
||||
expect(listener).toHaveProperty("handler");
|
||||
expect(typeof listener.handler).toBe("function");
|
||||
if (typeof listener === typeof CustomWebsocketEventUserService){
|
||||
expect(listener).toHaveProperty("userService", mockUserService);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("GET_STATE event handler", () => {
|
||||
it("should include a handler for GET_STATE", () => {
|
||||
const getStateListener = listeners.find(l => l.event === WebsocketEventType.GET_STATE);
|
||||
expect(getStateListener).toBeDefined();
|
||||
});
|
||||
|
||||
it("should send the user's last state when the handler is called", () => {
|
||||
const getStateListener = listeners.find(l => l.event === WebsocketEventType.GET_STATE);
|
||||
|
||||
getStateListener!.handler({});
|
||||
const event = new GetStateEvent(mockWs);
|
||||
await event.handler();
|
||||
|
||||
expect(mockWs.send).toHaveBeenCalledOnce();
|
||||
expect(mockWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: "STATE", payload: mockWs.user.lastState }),
|
||||
JSON.stringify({ type: "STATE", payload: mockLastState }),
|
||||
{ binary: false }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET_SETTINGS event handler", () => {
|
||||
it("should include a handler for GET_SETTINGS", () => {
|
||||
const getSettingsListener = listeners.find(l => l.event === WebsocketEventType.GET_SETTINGS);
|
||||
expect(getSettingsListener).toBeDefined();
|
||||
});
|
||||
describe("GetSettingsEvent", () => {
|
||||
it("should send the user's settings when its handler is called", async () => {
|
||||
const mockTimezone = "America/New_York";
|
||||
const mockWs = createMockWebSocket({ timezone: mockTimezone });
|
||||
|
||||
it("should send the user's timezone when the handler is called", () => {
|
||||
const getSettingsListener = listeners.find(l => l.event === WebsocketEventType.GET_SETTINGS);
|
||||
|
||||
getSettingsListener!.handler({});
|
||||
const event = new GetSettingsEvent(mockWs);
|
||||
await event.handler();
|
||||
|
||||
expect(mockWs.send).toHaveBeenCalledOnce();
|
||||
expect(mockWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: "SETTINGS", payload: { timezone: mockWs.user.timezone } }),
|
||||
JSON.stringify({ type: "SETTINGS", payload: { timezone: mockTimezone } }),
|
||||
{ binary: false }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -2,14 +2,12 @@ import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from "vi
|
||||
import { WebsocketEventHandler } from "../../../src/utils/websocket/websocketEventHandler";
|
||||
import { ExtendedWebSocket } from "../../../src/interfaces/extendedWebsocket";
|
||||
import { CustomWebsocketEvent } from "../../../src/utils/websocket/websocketCustomEvents/customWebsocketEvent";
|
||||
import {UserService} from "../../../src/db/services/db/UserService";
|
||||
import {SpotifyTokenService} from "../../../src/db/services/spotifyTokenService";
|
||||
import {SpotifyPollingService} from "../../../src/services/spotifyPollingService";
|
||||
|
||||
describe("WebsocketEventHandler", () => {
|
||||
let mockWebSocket: Mocked<ExtendedWebSocket>;
|
||||
let websocketEventHandler: WebsocketEventHandler;
|
||||
let mockUserService: Mocked<UserService>;
|
||||
let mockSpotifyTokenService: Mocked<SpotifyTokenService>
|
||||
let mockSpotifyPollingService: Mocked<SpotifyPollingService>
|
||||
let registeredHandlers: Map<string, (...args: any[]) => void>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -32,11 +30,9 @@ describe("WebsocketEventHandler", () => {
|
||||
} as unknown as Mocked<ExtendedWebSocket>;
|
||||
|
||||
// not used in this test
|
||||
mockUserService = {} as Mocked<UserService>;
|
||||
mockSpotifyTokenService = {} as Mocked<SpotifyTokenService>;
|
||||
mockSpotifyPollingService = {} as Mocked<SpotifyPollingService>;
|
||||
|
||||
|
||||
websocketEventHandler = new WebsocketEventHandler(mockWebSocket, mockUserService, mockSpotifyTokenService);
|
||||
websocketEventHandler = new WebsocketEventHandler(mockWebSocket, mockSpotifyPollingService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from "vitest";
|
||||
import { WebsocketServerEventHandler } from "../../../src/utils/websocket/websocketServerEventHandler";
|
||||
import type { UserService } from "../../../src/db/services/db/UserService";
|
||||
import {UserService} from "../../../src/services/db/UserService";
|
||||
|
||||
const heartbeatSpy = vi.fn();
|
||||
vi.mock("../../../src/utils/websocket/websocketServerHeartbeatInterval", () => ({
|
||||
|
||||
@@ -5,9 +5,9 @@ import { ExtendedWebSocketServer } from "../src/websocket";
|
||||
import { WebsocketServerEventHandler } from "../src/utils/websocket/websocketServerEventHandler";
|
||||
import { WebsocketEventHandler } from "../src/utils/websocket/websocketEventHandler";
|
||||
import { getEventListeners } from "../src/utils/websocket/websocketCustomEvents/websocketEventUtils";
|
||||
import {UserService} from "../src/db/services/db/UserService";
|
||||
import {createMockSpotifyTokenService, createMockUserService} from "./helpers/testSetup";
|
||||
import {SpotifyTokenService} from "../src/db/services/spotifyTokenService";
|
||||
import { createMockUserService} from "./helpers/testSetup";
|
||||
import {UserService} from "../src/services/db/UserService";
|
||||
import {SpotifyPollingService} from "../src/services/spotifyPollingService";
|
||||
|
||||
let mockWssInstance: Mocked<WebSocketServer>;
|
||||
let mockServerEventHandler: Mocked<WebsocketServerEventHandler>;
|
||||
@@ -28,7 +28,7 @@ describe("ExtendedWebSocketServer", () => {
|
||||
let mockHttpServer: Mocked<Server>;
|
||||
let extendedWss: ExtendedWebSocketServer;
|
||||
let mockUserService: Mocked<UserService>;
|
||||
let mockSpotifyService : Mocked<SpotifyTokenService>;
|
||||
let mockSpotifyPollingService: Mocked<SpotifyPollingService>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -47,11 +47,11 @@ describe("ExtendedWebSocketServer", () => {
|
||||
close: vi.fn(),
|
||||
} as unknown as Mocked<WebSocketServer>;
|
||||
|
||||
mockSpotifyPollingService = {} as any;
|
||||
|
||||
mockUserService = createMockUserService();
|
||||
mockSpotifyService = createMockSpotifyTokenService() as any;
|
||||
|
||||
|
||||
extendedWss = new ExtendedWebSocketServer(mockHttpServer, mockUserService, mockSpotifyService);
|
||||
extendedWss = new ExtendedWebSocketServer(mockHttpServer, mockUserService, mockSpotifyPollingService);
|
||||
});
|
||||
|
||||
describe("Constructor and Setup", () => {
|
||||
@@ -121,7 +121,7 @@ describe("ExtendedWebSocketServer", () => {
|
||||
|
||||
it("should create and configure a WebsocketEventHandler for new clients", () => {
|
||||
connectionHandler(mockWsClient, {});
|
||||
expect(vi.mocked(WebsocketEventHandler)).toHaveBeenCalledWith(mockWsClient, mockUserService, mockSpotifyService);
|
||||
expect(vi.mocked(WebsocketEventHandler)).toHaveBeenCalledWith(mockWsClient, mockSpotifyPollingService);
|
||||
expect(mockClientEventHandler.enableErrorEvent).toHaveBeenCalled();
|
||||
expect(mockClientEventHandler.enablePongEvent).toHaveBeenCalled();
|
||||
expect(mockClientEventHandler.enableMessageEvent).toHaveBeenCalled();
|
||||
|
||||
Reference in New Issue
Block a user