|
| 1 | + |
| 2 | +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unsafe-return */ |
| 3 | +import assert from 'node:assert/strict'; |
| 4 | +import { Sequelize } from 'sequelize'; |
| 5 | +import itemsRoutes from '../../src/lib/embyEmulation/ServerAPI/routes/items/index.js'; |
| 6 | +import { Series, seriesColumns } from '../../src/models/series.js'; |
| 7 | +import { Episode, episodeColumns } from '../../src/models/episode.js'; |
| 8 | +import { Movie, movieColumns } from '../../src/models/movie.js'; |
| 9 | +import { TrackEpisode, trackEpisodesColumns } from '../../src/models/trackEpisode.js'; |
| 10 | +import { TrackMovie, trackMovieColumns } from '../../src/models/trackMovie.js'; |
| 11 | +import { File, fileColumns } from '../../src/models/file.js'; |
| 12 | +import { Stream, streamColumns } from '../../src/models/stream.js'; |
| 13 | +import { User, userColumns } from '../../src/models/user.js'; |
| 14 | +import { formatUuid, formatId } from '../../src/lib/embyEmulation/helpers.js'; |
| 15 | + |
| 16 | +const makeServer = () => { |
| 17 | + const handlers = new Map(); |
| 18 | + |
| 19 | + const register = (method: string) => (route: string, handler: any) => { |
| 20 | + handlers.set(`${method} ${route}`, handler); |
| 21 | + }; |
| 22 | + |
| 23 | + return { |
| 24 | + handlers, |
| 25 | + get: register('GET'), |
| 26 | + post: register('POST'), |
| 27 | + put: register('PUT'), |
| 28 | + delete: register('DELETE') |
| 29 | + }; |
| 30 | +}; |
| 31 | + |
| 32 | +const makeRes = () => ({ |
| 33 | + statusCode: 200, |
| 34 | + body: null as any, |
| 35 | + status(code: number) { |
| 36 | + this.statusCode = code; |
| 37 | + return this; |
| 38 | + }, |
| 39 | + send(payload: any) { |
| 40 | + this.body = payload; |
| 41 | + return this; |
| 42 | + } |
| 43 | +}); |
| 44 | + |
| 45 | +describe('Emby items routes', () => { |
| 46 | + let sequelize: Sequelize; |
| 47 | + let series1: Series; |
| 48 | + let ep1_1: Episode; |
| 49 | + let movie1: Movie; |
| 50 | + let file1: File; |
| 51 | + const userId = 1; |
| 52 | + const userIdUuid = formatUuid(userId); |
| 53 | + |
| 54 | + const mockArtworkUtils = { |
| 55 | + moviePosterPath: () => '/tmp/poster.jpg', |
| 56 | + movieFanartPath: () => '/tmp/fanart.jpg', |
| 57 | + seriesPosterPath: () => '/tmp/series.jpg', |
| 58 | + episodeBannerPath: () => '/tmp/episode.jpg' |
| 59 | + }; |
| 60 | + |
| 61 | + const mockEmbyEmulation: any = { |
| 62 | + serverId: 'test-server-id', |
| 63 | + sessions: {}, |
| 64 | + oblecto: { |
| 65 | + artworkUtils: mockArtworkUtils, |
| 66 | + config: { |
| 67 | + artwork: { |
| 68 | + poster: {}, |
| 69 | + fanart: {}, |
| 70 | + banner: {} |
| 71 | + } |
| 72 | + } |
| 73 | + } |
| 74 | + }; |
| 75 | + |
| 76 | + before(async () => { |
| 77 | + sequelize = new Sequelize({ |
| 78 | + dialect: 'sqlite', storage: ':memory:', logging: false |
| 79 | + }); |
| 80 | + |
| 81 | + // Initialize models |
| 82 | + Series.init(seriesColumns, { sequelize, modelName: 'Series' }); |
| 83 | + Episode.init(episodeColumns, { sequelize, modelName: 'Episode' }); |
| 84 | + Movie.init(movieColumns, { sequelize, modelName: 'Movie' }); |
| 85 | + TrackEpisode.init(trackEpisodesColumns, { sequelize, modelName: 'TrackEpisode' }); |
| 86 | + TrackMovie.init(trackMovieColumns, { sequelize, modelName: 'TrackMovie' }); |
| 87 | + File.init(fileColumns, { sequelize, modelName: 'File' }); |
| 88 | + Stream.init(streamColumns, { sequelize, modelName: 'Stream' }); |
| 89 | + User.init(userColumns, { sequelize, modelName: 'User' }); |
| 90 | + |
| 91 | + // Associations |
| 92 | + Episode.belongsTo(Series); |
| 93 | + Series.hasMany(Episode); |
| 94 | + TrackEpisode.belongsTo(Episode, { foreignKey: 'episodeId' }); |
| 95 | + Episode.hasMany(TrackEpisode, { foreignKey: 'episodeId' }); |
| 96 | + TrackMovie.belongsTo(Movie, { foreignKey: 'movieId' }); |
| 97 | + Movie.hasMany(TrackMovie, { foreignKey: 'movieId' }); |
| 98 | + |
| 99 | + Episode.hasMany(File); |
| 100 | + File.belongsTo(Episode); |
| 101 | + Movie.hasMany(File); |
| 102 | + File.belongsTo(Movie); |
| 103 | + |
| 104 | + File.hasMany(Stream); |
| 105 | + Stream.belongsTo(File); |
| 106 | + |
| 107 | + await sequelize.sync({ force: true }); |
| 108 | + |
| 109 | + series1 = await Series.create({ seriesName: 'Test Series', firstAired: '2021-01-01' }); |
| 110 | + |
| 111 | + ep1_1 = await Episode.create({ |
| 112 | + episodeName: 'S1E1', |
| 113 | + airedSeason: 1, |
| 114 | + airedEpisodeNumber: 1, |
| 115 | + SeriesId: series1.id |
| 116 | + }); |
| 117 | + |
| 118 | + movie1 = await Movie.create({ |
| 119 | + movieName: 'Test Movie', |
| 120 | + releaseDate: '2020-01-01' |
| 121 | + }); |
| 122 | + |
| 123 | + file1 = await File.create({ path: '/tmp/movie.mkv', MovieId: movie1.id }); |
| 124 | + }); |
| 125 | + |
| 126 | + describe('GET /items', () => { |
| 127 | + it('should return all items', async () => { |
| 128 | + const server = makeServer(); |
| 129 | + itemsRoutes(server as any, mockEmbyEmulation); |
| 130 | + |
| 131 | + const handler = server.handlers.get('GET /items'); |
| 132 | + const req = { query: { IncludeItemTypes: 'Movie,Series,Episode' } }; |
| 133 | + const res = makeRes(); |
| 134 | + |
| 135 | + await handler(req, res); |
| 136 | + |
| 137 | + assert.equal(res.body.TotalRecordCount, 3); // 1 movie, 1 series, 1 episode |
| 138 | + }); |
| 139 | + |
| 140 | + it('should filter by SearchTerm', async () => { |
| 141 | + const server = makeServer(); |
| 142 | + itemsRoutes(server as any, mockEmbyEmulation); |
| 143 | + |
| 144 | + const handler = server.handlers.get('GET /items'); |
| 145 | + const req = { query: { IncludeItemTypes: 'Movie', SearchTerm: 'Test Movie' } }; |
| 146 | + const res = makeRes(); |
| 147 | + |
| 148 | + await handler(req, res); |
| 149 | + |
| 150 | + assert.equal(res.body.TotalRecordCount, 1); |
| 151 | + assert.equal(res.body.Items[0].Name, 'Test Movie'); |
| 152 | + }); |
| 153 | + }); |
| 154 | + |
| 155 | + describe('GET /items/:mediaid', () => { |
| 156 | + it('should return movie details', async () => { |
| 157 | + const server = makeServer(); |
| 158 | + itemsRoutes(server as any, mockEmbyEmulation); |
| 159 | + |
| 160 | + const handler = server.handlers.get('GET /items/:mediaid'); |
| 161 | + const req = { params: { mediaid: formatId(movie1.id, 'movie') } }; |
| 162 | + const res = makeRes(); |
| 163 | + |
| 164 | + await handler(req, res); |
| 165 | + |
| 166 | + assert.equal(res.body.Name, 'Test Movie'); |
| 167 | + }); |
| 168 | + |
| 169 | + it('should return "shows" folder info', async () => { |
| 170 | + const server = makeServer(); |
| 171 | + itemsRoutes(server as any, mockEmbyEmulation); |
| 172 | + |
| 173 | + const handler = server.handlers.get('GET /items/:mediaid'); |
| 174 | + const req = { params: { mediaid: 'shows' } }; |
| 175 | + const res = makeRes(); |
| 176 | + |
| 177 | + await handler(req, res); |
| 178 | + |
| 179 | + assert.equal(res.body.Name, 'Shows'); |
| 180 | + }); |
| 181 | + }); |
| 182 | + |
| 183 | + describe('GET /search/hints', () => { |
| 184 | + it('should return hints matching search term', async () => { |
| 185 | + const server = makeServer(); |
| 186 | + itemsRoutes(server as any, mockEmbyEmulation); |
| 187 | + |
| 188 | + const handler = server.handlers.get('GET /search/hints'); |
| 189 | + const req = { query: { SearchTerm: 'Test' } }; // Matches 'Test Series' and 'Test Movie' |
| 190 | + const res = makeRes(); |
| 191 | + |
| 192 | + await handler(req, res); |
| 193 | + |
| 194 | + assert.ok(res.body.TotalRecordCount >= 2); |
| 195 | + assert.ok(res.body.SearchHints.some((h: any) => h.Name === 'Test Movie')); |
| 196 | + }); |
| 197 | + }); |
| 198 | + |
| 199 | + describe('POST /items/:mediaid/playbackinfo', () => { |
| 200 | + it('should return playback info for movie', async () => { |
| 201 | + const server = makeServer(); |
| 202 | + itemsRoutes(server as any, mockEmbyEmulation); |
| 203 | + |
| 204 | + const handler = server.handlers.get('POST /items/:mediaid/playbackinfo'); |
| 205 | + const req = { |
| 206 | + params: { mediaid: formatId(movie1.id, 'movie') }, |
| 207 | + headers: { emby: { Token: 'test-token' } }, |
| 208 | + query: {} |
| 209 | + }; |
| 210 | + const res = makeRes(); |
| 211 | + |
| 212 | + // Mock session for token |
| 213 | + mockEmbyEmulation.sessions['test-token'] = { Id: userIdUuid }; |
| 214 | + |
| 215 | + await handler(req, res); |
| 216 | + |
| 217 | + assert.ok(res.body.MediaSources); |
| 218 | + assert.equal(res.body.MediaSources.length, 1); |
| 219 | + }); |
| 220 | + }); |
| 221 | + |
| 222 | + describe('Additional stub routes', () => { |
| 223 | + it('should return empty lists or defaults for stub routes', async () => { |
| 224 | + const server = makeServer(); |
| 225 | + itemsRoutes(server as any, mockEmbyEmulation); |
| 226 | + |
| 227 | + const routes = [ |
| 228 | + 'GET /items/filters', |
| 229 | + 'GET /items/filters2', |
| 230 | + 'GET /items/:itemid/images', |
| 231 | + 'GET /items/:itemid/instantmix', |
| 232 | + 'GET /items/:itemid/externalidinfos', |
| 233 | + 'GET /items/:itemid/criticreviews', |
| 234 | + 'GET /items/:itemid/themesongs', |
| 235 | + 'GET /items/:itemid/themevideos', |
| 236 | + 'GET /items/counts', |
| 237 | + 'GET /items/:itemid/remoteimages', |
| 238 | + 'GET /items/:itemid/remoteimages/providers', |
| 239 | + 'GET /items/suggestions', |
| 240 | + 'GET /items/:itemid/intros', |
| 241 | + 'GET /items/:itemid/localtrailers', |
| 242 | + 'GET /items/:itemid/specialfeatures', |
| 243 | + 'GET /items/root', |
| 244 | + 'GET /movies/:itemid/similar', |
| 245 | + 'GET /movies/recommendations', |
| 246 | + 'GET /shows/:itemid/similar', |
| 247 | + 'GET /shows/upcoming', |
| 248 | + 'GET /trailers', |
| 249 | + 'GET /trailers/:itemid/similar' |
| 250 | + ]; |
| 251 | + |
| 252 | + for (const route of routes) { |
| 253 | + const [method, path] = route.split(' '); |
| 254 | + const handler = server.handlers.get(route); |
| 255 | + |
| 256 | + if (!handler) { |
| 257 | + throw new Error(`Handler not found for ${route}`); |
| 258 | + } |
| 259 | + |
| 260 | + const req = { params: { itemid: '1', mediaid: '1' } }; |
| 261 | + const res = makeRes(); |
| 262 | + |
| 263 | + await handler(req, res); |
| 264 | + assert.ok(res.body, `Response body should exist for ${route}`); |
| 265 | + } |
| 266 | + }); |
| 267 | + |
| 268 | + it('should return 404 for download/file routes', async () => { |
| 269 | + const server = makeServer(); |
| 270 | + itemsRoutes(server as any, mockEmbyEmulation); |
| 271 | + |
| 272 | + const routes = [ |
| 273 | + 'GET /items/:itemid/download', |
| 274 | + 'GET /items/:itemid/file', |
| 275 | + 'GET /items/:itemid/remoteimages/download' |
| 276 | + ]; |
| 277 | + |
| 278 | + for (const route of routes) { |
| 279 | + const handler = server.handlers.get(route); |
| 280 | + const req = { params: { itemid: '1' } }; |
| 281 | + const res = makeRes(); |
| 282 | + |
| 283 | + await handler(req, res); |
| 284 | + assert.equal(res.statusCode, 404, `Should be 404 for ${route}`); |
| 285 | + } |
| 286 | + }); |
| 287 | + |
| 288 | + it('should return 204 for post actions', async () => { |
| 289 | + const server = makeServer(); |
| 290 | + itemsRoutes(server as any, mockEmbyEmulation); |
| 291 | + |
| 292 | + const routes = [ |
| 293 | + 'POST /items/remotesearch/apply/:itemid', |
| 294 | + 'POST /items/:itemid/refresh' |
| 295 | + ]; |
| 296 | + |
| 297 | + for (const route of routes) { |
| 298 | + const handler = server.handlers.get(route); |
| 299 | + const req = { params: { itemid: '1' } }; |
| 300 | + const res = makeRes(); |
| 301 | + |
| 302 | + await handler(req, res); |
| 303 | + assert.equal(res.statusCode, 204, `Should be 204 for ${route}`); |
| 304 | + } |
| 305 | + }); |
| 306 | + }); |
| 307 | +}); |
0 commit comments