From 37b95582a21c573a618c5bf95e542e4db83555d7 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sun, 22 Mar 2026 20:29:11 -0400 Subject: [PATCH] Add backend support for users to follow series Introduces a UserSeriesFollow model with a dedicated join table so users can follow/unfollow series and receive socket notifications when new books are added to followed series. The user JSON response now includes a seriesFollowing array, and three new API endpoints are available under /api/me/follows/. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/Database.js | 6 + server/controllers/MeController.js | 82 ++++ .../v2.34.0-create-user-series-follows.js | 110 +++++ server/models/User.js | 22 +- server/models/UserSeriesFollow.js | 84 ++++ server/routers/ApiRouter.js | 3 + server/scanner/LibraryScanner.js | 3 + server/utils/followNotifications.js | 50 +++ .../controllers/MeController.follows.test.js | 389 ++++++++++++++++++ 9 files changed, 744 insertions(+), 5 deletions(-) create mode 100644 server/migrations/v2.34.0-create-user-series-follows.js create mode 100644 server/models/UserSeriesFollow.js create mode 100644 server/utils/followNotifications.js create mode 100644 test/server/controllers/MeController.follows.test.js diff --git a/server/Database.js b/server/Database.js index 213c2c61..a15dd702 100644 --- a/server/Database.js +++ b/server/Database.js @@ -162,6 +162,11 @@ class Database { return this.models.device } + /** @type {typeof import('./models/UserSeriesFollow')} */ + get userSeriesFollowModel() { + return this.models.userSeriesFollow + } + /** * Check if db file exists * @returns {boolean} @@ -345,6 +350,7 @@ class Database { require('./models/Setting').init(this.sequelize) require('./models/CustomMetadataProvider').init(this.sequelize) require('./models/MediaItemShare').init(this.sequelize) + require('./models/UserSeriesFollow').init(this.sequelize) return this.sequelize.sync({ force, alter: false }) } diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index c5968f52..97b19d0f 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -513,5 +513,87 @@ class MeController { const data = await userStats.getStatsForYear(req.user.id, year) res.json(data) } + /** + * POST: /api/me/follows/series/:id + * Follow a series + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async followSeries(req, res) { + const series = await Database.seriesModel.findByPk(req.params.id) + if (!series) { + Logger.error(`[MeController] followSeries: Series ${req.params.id} not found`) + return res.sendStatus(404) + } + + const existing = await Database.userSeriesFollowModel.findOne({ + where: { userId: req.user.id, seriesId: series.id } + }) + if (!existing) { + await Database.userSeriesFollowModel.create({ + userId: req.user.id, + seriesId: series.id + }) + Database.userModel.seriesFollowChanged(req.user.id) + } + + const seriesFollowing = await Database.userSeriesFollowModel.getFollowedSeriesIdsForUser(req.user.id) + SocketAuthority.clientEmitter(req.user.id, 'user_series_follows_updated', { seriesFollowing }) + + res.sendStatus(200) + } + + /** + * DELETE: /api/me/follows/series/:id + * Unfollow a series + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async unfollowSeries(req, res) { + const deleted = await Database.userSeriesFollowModel.destroy({ + where: { userId: req.user.id, seriesId: req.params.id } + }) + if (!deleted) { + return res.sendStatus(404) + } + + Database.userModel.seriesFollowChanged(req.user.id) + + const seriesFollowing = await Database.userSeriesFollowModel.getFollowedSeriesIdsForUser(req.user.id) + SocketAuthority.clientEmitter(req.user.id, 'user_series_follows_updated', { seriesFollowing }) + + res.sendStatus(200) + } + + /** + * GET: /api/me/follows + * Get all follows for the current user + * Optional query param: ?type=series + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getFollows(req, res) { + const type = req.query.type + const result = {} + + if (!type || type === 'series') { + const seriesFollows = await Database.userSeriesFollowModel.findAll({ + where: { userId: req.user.id }, + include: { model: Database.seriesModel, attributes: ['id', 'name', 'libraryId'] }, + order: [['createdAt', 'DESC']] + }) + result.series = seriesFollows.map((sf) => ({ + seriesId: sf.seriesId, + seriesName: sf.series?.name || null, + libraryId: sf.series?.libraryId || null, + createdAt: sf.createdAt.valueOf() + })) + } + + res.json(result) + } } module.exports = new MeController() diff --git a/server/migrations/v2.34.0-create-user-series-follows.js b/server/migrations/v2.34.0-create-user-series-follows.js new file mode 100644 index 00000000..180f1447 --- /dev/null +++ b/server/migrations/v2.34.0-create-user-series-follows.js @@ -0,0 +1,110 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.34.0' +const migrationName = `${migrationVersion}-create-user-series-follows` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This upward migration creates the userSeriesFollows table for tracking + * which users follow which series. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + if (await queryInterface.tableExists('userSeriesFollows')) { + logger.info(`${loggerPrefix} table "userSeriesFollows" already exists`) + } else { + logger.info(`${loggerPrefix} creating table "userSeriesFollows"`) + const DataTypes = queryInterface.sequelize.Sequelize.DataTypes + await queryInterface.createTable('userSeriesFollows', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + userId: { + type: DataTypes.UUID, + references: { + model: { + tableName: 'users' + }, + key: 'id' + }, + allowNull: false, + onDelete: 'CASCADE' + }, + seriesId: { + type: DataTypes.UUID, + references: { + model: { + tableName: 'series' + }, + key: 'id' + }, + allowNull: false, + onDelete: 'CASCADE' + } + }) + logger.info(`${loggerPrefix} created table "userSeriesFollows"`) + + // Index for "get all series a user follows" (most frequent query) + await queryInterface.addIndex('userSeriesFollows', { + name: 'user_series_follows_userId', + fields: ['userId'] + }) + logger.info(`${loggerPrefix} added index on userId`) + + // Unique constraint to prevent duplicate follows + await queryInterface.addIndex('userSeriesFollows', { + name: 'user_series_follows_unique', + fields: ['userId', 'seriesId'], + unique: true + }) + logger.info(`${loggerPrefix} added unique index on (userId, seriesId)`) + + // Index for "find all followers of series X" (for notifications) + await queryInterface.addIndex('userSeriesFollows', { + name: 'user_series_follows_seriesId', + fields: ['seriesId'] + }) + logger.info(`${loggerPrefix} added index on seriesId`) + } + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This downward migration script removes the userSeriesFollows table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + if (await queryInterface.tableExists('userSeriesFollows')) { + logger.info(`${loggerPrefix} dropping table "userSeriesFollows"`) + await queryInterface.dropTable('userSeriesFollows') + logger.info(`${loggerPrefix} dropped table "userSeriesFollows"`) + } else { + logger.info(`${loggerPrefix} table "userSeriesFollows" does not exist`) + } + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +module.exports = { up, down } diff --git a/server/models/User.js b/server/models/User.js index 936efde1..3744815c 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -357,7 +357,7 @@ class User extends Model { [sequelize.Op.like]: username } }, - include: this.sequelize.models.mediaProgress + include: [this.sequelize.models.mediaProgress, this.sequelize.models.userSeriesFollow] }) if (user) userCache.set(user) @@ -382,7 +382,7 @@ class User extends Model { [sequelize.Op.like]: email } }, - include: this.sequelize.models.mediaProgress + include: [this.sequelize.models.mediaProgress, this.sequelize.models.userSeriesFollow] }) if (user) userCache.set(user) @@ -402,7 +402,7 @@ class User extends Model { if (cachedUser) return cachedUser const user = await this.findByPk(userId, { - include: this.sequelize.models.mediaProgress + include: [this.sequelize.models.mediaProgress, this.sequelize.models.userSeriesFollow] }) if (user) userCache.set(user) @@ -426,7 +426,7 @@ class User extends Model { where: { [sequelize.Op.or]: [{ id: userId }, { 'extraData.oldUserId': userId }] }, - include: this.sequelize.models.mediaProgress + include: [this.sequelize.models.mediaProgress, this.sequelize.models.userSeriesFollow] }) if (user) userCache.set(user) @@ -447,7 +447,7 @@ class User extends Model { const user = await this.findOne({ where: sequelize.where(sequelize.literal(`extraData->>"authOpenIDSub"`), sub), - include: this.sequelize.models.mediaProgress + include: [this.sequelize.models.mediaProgress, this.sequelize.models.userSeriesFollow] }) if (user) userCache.set(user) @@ -506,6 +506,17 @@ class User extends Model { } } + /** + * Invalidate cached user when series follows change + * @param {string} userId + */ + static seriesFollowChanged(userId) { + const cachedUser = userCache.getById(userId) + if (cachedUser) { + userCache.delete(userId) + } + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize @@ -621,6 +632,7 @@ class User extends Model { isOldToken: this.isOldToken, mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [], seriesHideFromContinueListening: [...seriesHideFromContinueListening], + seriesFollowing: this.userSeriesFollows?.map((f) => f.seriesId) || [], bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [], isActive: this.isActive, isLocked: this.isLocked, diff --git a/server/models/UserSeriesFollow.js b/server/models/UserSeriesFollow.js new file mode 100644 index 00000000..8e085ec1 --- /dev/null +++ b/server/models/UserSeriesFollow.js @@ -0,0 +1,84 @@ +const { DataTypes, Model } = require('sequelize') + +class UserSeriesFollow extends Model { + constructor(values, options) { + super(values, options) + + /** @type {UUIDV4} */ + this.id + /** @type {UUIDV4} */ + this.userId + /** @type {UUIDV4} */ + this.seriesId + /** @type {Date} */ + this.createdAt + } + + /** + * Get array of series IDs that a user is following + * @param {string} userId + * @returns {Promise} + */ + static async getFollowedSeriesIdsForUser(userId) { + const follows = await this.findAll({ + where: { userId }, + attributes: ['seriesId'] + }) + return follows.map((f) => f.seriesId) + } + + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + } + }, + { + sequelize, + modelName: 'userSeriesFollow', + timestamps: true, + updatedAt: false, + indexes: [ + { + name: 'user_series_follows_userId', + fields: ['userId'] + }, + { + name: 'user_series_follows_unique', + fields: ['userId', 'seriesId'], + unique: true + }, + { + name: 'user_series_follows_seriesId', + fields: ['seriesId'] + } + ] + } + ) + + // Super Many-to-Many + // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship + const { user, series } = sequelize.models + user.belongsToMany(series, { through: UserSeriesFollow, as: 'followedSeries' }) + series.belongsToMany(user, { through: UserSeriesFollow, as: 'followers' }) + + user.hasMany(UserSeriesFollow, { + onDelete: 'CASCADE' + }) + UserSeriesFollow.belongsTo(user) + + series.hasMany(UserSeriesFollow, { + onDelete: 'CASCADE' + }) + UserSeriesFollow.belongsTo(series) + } +} + +module.exports = UserSeriesFollow diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index db04bf5e..92e692c7 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -188,6 +188,9 @@ class ApiRouter { this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this)) this.router.get('/me/stats/year/:year', MeController.getStatsForYear.bind(this)) this.router.post('/me/ereader-devices', MeController.updateUserEReaderDevices.bind(this)) + this.router.post('/me/follows/series/:id', MeController.followSeries.bind(this)) + this.router.delete('/me/follows/series/:id', MeController.unfollowSeries.bind(this)) + this.router.get('/me/follows', MeController.getFollows.bind(this)) // // Backup Routes diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index 640c82d7..bae210b0 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -13,6 +13,7 @@ const TaskManager = require('../managers/TaskManager') const LibraryItemScanner = require('./LibraryItemScanner') const LibraryScan = require('./LibraryScan') const LibraryItemScanData = require('./LibraryItemScanData') +const { notifyFollowersOfNewBook } = require('../utils/followNotifications') const Task = require('../objects/Task') class LibraryScanner { @@ -263,6 +264,7 @@ class LibraryScanner { const newLibraryItem = await LibraryItemScanner.scanNewLibraryItem(libraryItemData, libraryScan.library.settings, libraryScan) if (newLibraryItem) { newLibraryItems.push(newLibraryItem) + notifyFollowersOfNewBook(newLibraryItem) libraryScan.resultsAdded++ } @@ -633,6 +635,7 @@ class LibraryScanner { const newLibraryItem = await LibraryItemScanner.scanPotentialNewLibraryItem(fullPath, library, folder, isSingleMediaItem) if (newLibraryItem) { SocketAuthority.libraryItemEmitter('item_added', newLibraryItem) + notifyFollowersOfNewBook(newLibraryItem) } itemGroupingResults[itemDir] = newLibraryItem ? ScanResult.ADDED : ScanResult.NOTHING } diff --git a/server/utils/followNotifications.js b/server/utils/followNotifications.js new file mode 100644 index 00000000..a2d1394d --- /dev/null +++ b/server/utils/followNotifications.js @@ -0,0 +1,50 @@ +const Database = require('../Database') +const SocketAuthority = require('../SocketAuthority') +const Logger = require('../Logger') + +/** + * Notify users following a series that a new book was added. + * Called after a new library item is created by the scanner. + * + * @param {import('../models/LibraryItem')} libraryItem - the newly created expanded library item + */ +async function notifyFollowersOfNewBook(libraryItem) { + if (libraryItem.mediaType !== 'book') return + if (!libraryItem.media?.series?.length) return + + const seriesIds = libraryItem.media.series.map((s) => s.id) + if (!seriesIds.length) return + + const follows = await Database.userSeriesFollowModel.findAll({ + where: { seriesId: seriesIds }, + attributes: ['userId', 'seriesId'] + }) + + if (!follows.length) return + + // Group by userId to send one event per user + const userSeriesMap = {} + for (const follow of follows) { + if (!userSeriesMap[follow.userId]) { + userSeriesMap[follow.userId] = [] + } + userSeriesMap[follow.userId].push(follow.seriesId) + } + + const libraryItemJson = libraryItem.toOldJSONExpanded() + + for (const [userId, followedSeriesIds] of Object.entries(userSeriesMap)) { + const matchedSeries = libraryItem.media.series + .filter((s) => followedSeriesIds.includes(s.id)) + .map((s) => ({ id: s.id, name: s.name })) + + SocketAuthority.clientEmitter(userId, 'followed_series_book_added', { + libraryItem: libraryItemJson, + series: matchedSeries + }) + + Logger.debug(`[FollowNotifications] Notified user ${userId} of new book in followed series: ${matchedSeries.map((s) => s.name).join(', ')}`) + } +} + +module.exports = { notifyFollowersOfNewBook } diff --git a/test/server/controllers/MeController.follows.test.js b/test/server/controllers/MeController.follows.test.js new file mode 100644 index 00000000..07a2b823 --- /dev/null +++ b/test/server/controllers/MeController.follows.test.js @@ -0,0 +1,389 @@ +const { expect } = require('chai') +const { Sequelize } = require('sequelize') +const sinon = require('sinon') + +const Database = require('../../../server/Database') +const MeController = require('../../../server/controllers/MeController') +const Logger = require('../../../server/Logger') +const SocketAuthority = require('../../../server/SocketAuthority') + +describe('MeController - Series Follow Tests', () => { + let user1, user2, library, series1, series2 + + beforeEach(async () => { + global.ServerSettings = {} + Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') + await Database.buildModels() + + sinon.stub(Logger, 'info') + sinon.stub(Logger, 'error') + sinon.stub(Logger, 'debug') + sinon.stub(SocketAuthority, 'clientEmitter') + + // Create test data + library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) + + user1 = await Database.userModel.create({ + username: 'user1', + pash: 'hashed_password_1', + type: 'user', + isActive: true + }) + user1.mediaProgresses = [] + user1.userSeriesFollows = [] + + user2 = await Database.userModel.create({ + username: 'user2', + pash: 'hashed_password_2', + type: 'user', + isActive: true + }) + user2.mediaProgresses = [] + user2.userSeriesFollows = [] + + series1 = await Database.seriesModel.create({ + name: 'Test Series 1', + nameIgnorePrefix: 'Test Series 1', + libraryId: library.id + }) + + series2 = await Database.seriesModel.create({ + name: 'Test Series 2', + nameIgnorePrefix: 'Test Series 2', + libraryId: library.id + }) + }) + + afterEach(async () => { + sinon.restore() + await Database.sequelize.sync({ force: true }) + }) + + describe('followSeries', () => { + it('should follow a series successfully', async () => { + const fakeReq = { + user: user1, + params: { id: series1.id } + } + const fakeRes = { + sendStatus: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.followSeries(fakeReq, fakeRes) + + expect(fakeRes.sendStatus.calledWith(200)).to.be.true + + // Verify follow record was created + const follows = await Database.userSeriesFollowModel.findAll({ + where: { userId: user1.id } + }) + expect(follows).to.have.length(1) + expect(follows[0].seriesId).to.equal(series1.id) + }) + + it('should return 404 for non-existent series', async () => { + const fakeReq = { + user: user1, + params: { id: '00000000-0000-0000-0000-000000000000' } + } + const fakeRes = { + sendStatus: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.followSeries(fakeReq, fakeRes) + + expect(fakeRes.sendStatus.calledWith(404)).to.be.true + }) + + it('should be idempotent - following twice creates only one record', async () => { + const fakeReq = { + user: user1, + params: { id: series1.id } + } + const fakeRes = { + sendStatus: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.followSeries(fakeReq, fakeRes) + await MeController.followSeries(fakeReq, fakeRes) + + expect(fakeRes.sendStatus.calledWith(200)).to.be.true + + const follows = await Database.userSeriesFollowModel.findAll({ + where: { userId: user1.id } + }) + expect(follows).to.have.length(1) + }) + + it('should emit user_series_follows_updated socket event', async () => { + const fakeReq = { + user: user1, + params: { id: series1.id } + } + const fakeRes = { + sendStatus: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.followSeries(fakeReq, fakeRes) + + expect(SocketAuthority.clientEmitter.calledOnce).to.be.true + const [userId, event, data] = SocketAuthority.clientEmitter.firstCall.args + expect(userId).to.equal(user1.id) + expect(event).to.equal('user_series_follows_updated') + expect(data.seriesFollowing).to.include(series1.id) + }) + }) + + describe('unfollowSeries', () => { + beforeEach(async () => { + await Database.userSeriesFollowModel.create({ + userId: user1.id, + seriesId: series1.id + }) + }) + + it('should unfollow a series successfully', async () => { + const fakeReq = { + user: user1, + params: { id: series1.id } + } + const fakeRes = { + sendStatus: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.unfollowSeries(fakeReq, fakeRes) + + expect(fakeRes.sendStatus.calledWith(200)).to.be.true + + const follows = await Database.userSeriesFollowModel.findAll({ + where: { userId: user1.id } + }) + expect(follows).to.have.length(0) + }) + + it('should return 404 when not following', async () => { + const fakeReq = { + user: user1, + params: { id: series2.id } + } + const fakeRes = { + sendStatus: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.unfollowSeries(fakeReq, fakeRes) + + expect(fakeRes.sendStatus.calledWith(404)).to.be.true + }) + + it('should emit user_series_follows_updated socket event', async () => { + const fakeReq = { + user: user1, + params: { id: series1.id } + } + const fakeRes = { + sendStatus: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.unfollowSeries(fakeReq, fakeRes) + + expect(SocketAuthority.clientEmitter.calledOnce).to.be.true + const [userId, event, data] = SocketAuthority.clientEmitter.firstCall.args + expect(userId).to.equal(user1.id) + expect(event).to.equal('user_series_follows_updated') + expect(data.seriesFollowing).to.not.include(series1.id) + }) + }) + + describe('getFollows', () => { + beforeEach(async () => { + await Database.userSeriesFollowModel.create({ + userId: user1.id, + seriesId: series1.id + }) + await Database.userSeriesFollowModel.create({ + userId: user1.id, + seriesId: series2.id + }) + // user2 follows series1 - should not appear in user1's results + await Database.userSeriesFollowModel.create({ + userId: user2.id, + seriesId: series1.id + }) + }) + + it('should return all followed series for the user', async () => { + const fakeReq = { + user: user1, + query: {} + } + const fakeRes = { + json: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.getFollows(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + const result = fakeRes.json.firstCall.args[0] + expect(result.series).to.have.length(2) + expect(result.series.map((s) => s.seriesId)).to.include(series1.id) + expect(result.series.map((s) => s.seriesId)).to.include(series2.id) + }) + + it('should not return follows from other users', async () => { + const fakeReq = { + user: user2, + query: {} + } + const fakeRes = { + json: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.getFollows(fakeReq, fakeRes) + + const result = fakeRes.json.firstCall.args[0] + expect(result.series).to.have.length(1) + expect(result.series[0].seriesId).to.equal(series1.id) + }) + + it('should return empty array when no follows', async () => { + const user3 = await Database.userModel.create({ + username: 'user3', + pash: 'hashed_password_3', + type: 'user', + isActive: true + }) + const fakeReq = { + user: user3, + query: {} + } + const fakeRes = { + json: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.getFollows(fakeReq, fakeRes) + + const result = fakeRes.json.firstCall.args[0] + expect(result.series).to.have.length(0) + }) + + it('should include series name and libraryId', async () => { + const fakeReq = { + user: user1, + query: { type: 'series' } + } + const fakeRes = { + json: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.getFollows(fakeReq, fakeRes) + + const result = fakeRes.json.firstCall.args[0] + const followedSeries = result.series.find((s) => s.seriesId === series1.id) + expect(followedSeries.seriesName).to.equal('Test Series 1') + expect(followedSeries.libraryId).to.equal(library.id) + expect(followedSeries.createdAt).to.be.a('number') + }) + }) + + describe('toOldJSONForBrowser includes seriesFollowing', () => { + it('should include seriesFollowing array in user JSON', async () => { + await Database.userSeriesFollowModel.create({ + userId: user1.id, + seriesId: series1.id + }) + + // Reload user with follows + const reloadedUser = await Database.userModel.findByPk(user1.id, { + include: [Database.sequelize.models.mediaProgress, Database.sequelize.models.userSeriesFollow] + }) + + const json = reloadedUser.toOldJSONForBrowser() + expect(json.seriesFollowing).to.be.an('array') + expect(json.seriesFollowing).to.include(series1.id) + }) + + it('should return empty seriesFollowing when no follows', () => { + const json = user1.toOldJSONForBrowser() + expect(json.seriesFollowing).to.be.an('array') + expect(json.seriesFollowing).to.have.length(0) + }) + }) + + describe('cascade deletion', () => { + it('should clean up follows when series is deleted', async () => { + await Database.userSeriesFollowModel.create({ + userId: user1.id, + seriesId: series1.id + }) + + await series1.destroy() + + const follows = await Database.userSeriesFollowModel.findAll({ + where: { userId: user1.id } + }) + expect(follows).to.have.length(0) + }) + + it('should clean up follows when user is deleted', async () => { + await Database.userSeriesFollowModel.create({ + userId: user1.id, + seriesId: series1.id + }) + + await user1.destroy() + + const follows = await Database.userSeriesFollowModel.findAll({ + where: { seriesId: series1.id } + }) + expect(follows).to.have.length(0) + }) + }) + + describe('getFollowedSeriesIdsForUser', () => { + it('should return array of series IDs', async () => { + await Database.userSeriesFollowModel.create({ + userId: user1.id, + seriesId: series1.id + }) + await Database.userSeriesFollowModel.create({ + userId: user1.id, + seriesId: series2.id + }) + + const ids = await Database.userSeriesFollowModel.getFollowedSeriesIdsForUser(user1.id) + expect(ids).to.be.an('array') + expect(ids).to.have.length(2) + expect(ids).to.include(series1.id) + expect(ids).to.include(series2.id) + }) + + it('should return empty array when no follows', async () => { + const ids = await Database.userSeriesFollowModel.getFollowedSeriesIdsForUser(user1.id) + expect(ids).to.be.an('array') + expect(ids).to.have.length(0) + }) + }) +})