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