major refactoring and use spotify polling service instead of update every second

This commit is contained in:
StarAppeal
2025-09-20 20:37:47 +02:00
parent 01c0872459
commit 22b5d7a4e4
38 changed files with 843 additions and 647 deletions
-45
View File
@@ -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);
}
}
+21
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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();
}
+31
View File
@@ -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;
}
}
}
+105
View File
@@ -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";
+2 -1
View File
@@ -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),
+3 -4
View File
@@ -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
View File
@@ -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 {
-339
View File
@@ -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 ");
});
});
});
+7 -2
View File
@@ -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(),
})
+1 -1
View File
@@ -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()
}
-1
View File
@@ -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");
+3 -3
View File
@@ -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,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 {
+334
View File
@@ -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);
});
});
});
@@ -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", () => ({
+8 -8
View File
@@ -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();