mirror of
https://github.com/seerr-team/seerr.git
synced 2026-06-10 09:37:53 -04:00
feat(api): support userId when creating issues (#3100)
This commit is contained in:
@@ -7592,6 +7592,9 @@ paths:
|
||||
type: string
|
||||
mediaId:
|
||||
type: number
|
||||
userId:
|
||||
type: number
|
||||
nullable: true
|
||||
responses:
|
||||
'201':
|
||||
description: Succesfully created the issue
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import type { IssueType } from '@server/constants/issue';
|
||||
import type Issue from '@server/entity/Issue';
|
||||
import type { PaginatedResponse } from './common';
|
||||
|
||||
export interface IssueResultsResponse extends PaginatedResponse {
|
||||
results: Issue[];
|
||||
}
|
||||
|
||||
export type IssueRequestBody = {
|
||||
message: string;
|
||||
mediaId: number;
|
||||
issueType: IssueType;
|
||||
problemSeason?: number;
|
||||
problemEpisode?: number;
|
||||
userId?: number;
|
||||
};
|
||||
|
||||
208
server/routes/issue.test.ts
Normal file
208
server/routes/issue.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { before, beforeEach, describe, it, mock } from 'node:test';
|
||||
|
||||
import { IssueType } from '@server/constants/issue';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Issue from '@server/entity/Issue';
|
||||
import Media from '@server/entity/Media';
|
||||
import { User } from '@server/entity/User';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import { checkUser } from '@server/middleware/auth';
|
||||
import { IssueSubscriber } from '@server/subscriber/IssueSubscriber';
|
||||
import { setupTestDb } from '@server/test/db';
|
||||
import type { Express } from 'express';
|
||||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
import request from 'supertest';
|
||||
import authRoutes from './auth';
|
||||
import issueRoutes from './issue';
|
||||
|
||||
const sendIssueNotificationMock = mock.method(
|
||||
IssueSubscriber.prototype as unknown as {
|
||||
sendIssueNotification: (...args: unknown[]) => Promise<void>;
|
||||
},
|
||||
'sendIssueNotification',
|
||||
async () => undefined
|
||||
).mock;
|
||||
|
||||
let app: Express;
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
session({
|
||||
secret: 'test-secret',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
})
|
||||
);
|
||||
app.use(checkUser);
|
||||
app.use('/auth', authRoutes);
|
||||
app.use('/issue', issueRoutes);
|
||||
app.use(
|
||||
(
|
||||
err: { status?: number; message?: string },
|
||||
_req: express.Request,
|
||||
res: express.Response,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_next: express.NextFunction
|
||||
) => {
|
||||
res
|
||||
.status(err.status ?? 500)
|
||||
.json({ status: err.status ?? 500, message: err.message });
|
||||
}
|
||||
);
|
||||
return app;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sendIssueNotificationMock.resetCalls();
|
||||
});
|
||||
|
||||
setupTestDb();
|
||||
|
||||
async function loginAs(email: string, password: string) {
|
||||
const settings = getSettings();
|
||||
const priorLocalLogin = settings.main.localLogin;
|
||||
settings.main.localLogin = true;
|
||||
|
||||
try {
|
||||
const agent = request.agent(app);
|
||||
const res = await agent.post('/auth/local').send({ email, password });
|
||||
assert.strictEqual(res.status, 200);
|
||||
return agent;
|
||||
} finally {
|
||||
settings.main.localLogin = priorLocalLogin;
|
||||
}
|
||||
}
|
||||
|
||||
async function seedMedia() {
|
||||
return getRepository(Media).save(
|
||||
new Media({
|
||||
mediaType: MediaType.MOVIE,
|
||||
tmdbId: 12345,
|
||||
status: MediaStatus.AVAILABLE,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe('POST /issue', () => {
|
||||
it('creates an issue on behalf of the supplied userId', async () => {
|
||||
const issueRepo = getRepository(Issue);
|
||||
const userRepo = getRepository(User);
|
||||
const media = await seedMedia();
|
||||
const friend = await userRepo.findOneOrFail({
|
||||
where: { email: 'friend@seerr.dev' },
|
||||
});
|
||||
|
||||
const agent = await loginAs('admin@seerr.dev', 'test1234');
|
||||
const res = await agent.post('/issue').send({
|
||||
issueType: IssueType.VIDEO,
|
||||
message: 'Playback stutters near the end.',
|
||||
mediaId: media.id,
|
||||
problemSeason: 0,
|
||||
problemEpisode: 0,
|
||||
userId: friend.id,
|
||||
});
|
||||
|
||||
assert.strictEqual(res.status, 201);
|
||||
assert.strictEqual(res.body.createdBy.email, 'friend@seerr.dev');
|
||||
assert.strictEqual(res.body.comments[0].user.email, 'friend@seerr.dev');
|
||||
|
||||
const persisted = await issueRepo.findOneOrFail({
|
||||
where: { id: res.body.id },
|
||||
});
|
||||
|
||||
assert.strictEqual(persisted.createdBy.id, friend.id);
|
||||
assert.strictEqual(persisted.comments[0].user.id, friend.id);
|
||||
});
|
||||
|
||||
it('defaults to the authenticated user when userId is omitted', async () => {
|
||||
const media = await seedMedia();
|
||||
|
||||
const agent = await loginAs('admin@seerr.dev', 'test1234');
|
||||
const res = await agent.post('/issue').send({
|
||||
issueType: IssueType.AUDIO,
|
||||
message: 'Audio is out of sync.',
|
||||
mediaId: media.id,
|
||||
});
|
||||
|
||||
assert.strictEqual(res.status, 201);
|
||||
assert.strictEqual(res.body.createdBy.email, 'admin@seerr.dev');
|
||||
assert.strictEqual(res.body.comments[0].user.email, 'admin@seerr.dev');
|
||||
});
|
||||
|
||||
it('allows creators to supply their own userId', async () => {
|
||||
const userRepo = getRepository(User);
|
||||
const media = await seedMedia();
|
||||
const friend = await userRepo.findOneOrFail({
|
||||
where: { email: 'friend@seerr.dev' },
|
||||
});
|
||||
|
||||
friend.permissions = Permission.CREATE_ISSUES;
|
||||
await userRepo.save(friend);
|
||||
|
||||
const agent = await loginAs('friend@seerr.dev', 'test1234');
|
||||
const res = await agent.post('/issue').send({
|
||||
issueType: IssueType.SUBTITLES,
|
||||
message: 'Subtitles are missing.',
|
||||
mediaId: media.id,
|
||||
userId: friend.id,
|
||||
});
|
||||
|
||||
assert.strictEqual(res.status, 201);
|
||||
assert.strictEqual(res.body.createdBy.email, 'friend@seerr.dev');
|
||||
assert.strictEqual(res.body.comments[0].user.email, 'friend@seerr.dev');
|
||||
});
|
||||
|
||||
it('prevents non-managers from supplying another userId', async () => {
|
||||
const userRepo = getRepository(User);
|
||||
const media = await seedMedia();
|
||||
const friend = await userRepo.findOneOrFail({
|
||||
where: { email: 'friend@seerr.dev' },
|
||||
});
|
||||
const admin = await userRepo.findOneOrFail({
|
||||
where: { email: 'admin@seerr.dev' },
|
||||
});
|
||||
|
||||
friend.permissions = Permission.CREATE_ISSUES;
|
||||
await userRepo.save(friend);
|
||||
|
||||
const agent = await loginAs('friend@seerr.dev', 'test1234');
|
||||
const res = await agent.post('/issue').send({
|
||||
issueType: IssueType.OTHER,
|
||||
message: 'Something else is wrong.',
|
||||
mediaId: media.id,
|
||||
userId: admin.id,
|
||||
});
|
||||
|
||||
assert.strictEqual(res.status, 403);
|
||||
assert.strictEqual(
|
||||
res.body.message,
|
||||
'You do not have permission to create an issue on behalf of another user.'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 404 when the supplied userId does not exist', async () => {
|
||||
const media = await seedMedia();
|
||||
|
||||
const agent = await loginAs('admin@seerr.dev', 'test1234');
|
||||
const res = await agent.post('/issue').send({
|
||||
issueType: IssueType.OTHER,
|
||||
message: 'Something else is wrong.',
|
||||
mediaId: media.id,
|
||||
userId: 999999,
|
||||
});
|
||||
|
||||
assert.strictEqual(res.status, 404);
|
||||
assert.strictEqual(res.body.message, 'Issue user not found');
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,11 @@ import { getRepository } from '@server/datasource';
|
||||
import Issue from '@server/entity/Issue';
|
||||
import IssueComment from '@server/entity/IssueComment';
|
||||
import Media from '@server/entity/Media';
|
||||
import type { IssueResultsResponse } from '@server/interfaces/api/issueInterfaces';
|
||||
import { User } from '@server/entity/User';
|
||||
import type {
|
||||
IssueRequestBody,
|
||||
IssueResultsResponse,
|
||||
} from '@server/interfaces/api/issueInterfaces';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
@@ -95,17 +99,7 @@ issueRoutes.get<Record<string, string>, IssueResultsResponse>(
|
||||
}
|
||||
);
|
||||
|
||||
issueRoutes.post<
|
||||
Record<string, string>,
|
||||
Issue,
|
||||
{
|
||||
message: string;
|
||||
mediaId: number;
|
||||
issueType: number;
|
||||
problemSeason: number;
|
||||
problemEpisode: number;
|
||||
}
|
||||
>(
|
||||
issueRoutes.post<Record<string, string>, Issue, IssueRequestBody>(
|
||||
'/',
|
||||
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
|
||||
type: 'or',
|
||||
@@ -118,6 +112,7 @@ issueRoutes.post<
|
||||
|
||||
const issueRepository = getRepository(Issue);
|
||||
const mediaRepository = getRepository(Media);
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: req.body.mediaId },
|
||||
@@ -127,15 +122,37 @@ issueRoutes.post<
|
||||
return next({ status: 404, message: 'Media does not exist.' });
|
||||
}
|
||||
|
||||
let createdBy = req.user;
|
||||
|
||||
if (req.body.userId != null && req.body.userId !== req.user.id) {
|
||||
if (!req.user.hasPermission(Permission.MANAGE_ISSUES)) {
|
||||
return next({
|
||||
status: 403,
|
||||
message:
|
||||
'You do not have permission to create an issue on behalf of another user.',
|
||||
});
|
||||
}
|
||||
|
||||
const user = await userRepository.findOne({
|
||||
where: { id: req.body.userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return next({ status: 404, message: 'Issue user not found' });
|
||||
}
|
||||
|
||||
createdBy = user;
|
||||
}
|
||||
|
||||
const issue = new Issue({
|
||||
createdBy: req.user,
|
||||
createdBy,
|
||||
issueType: req.body.issueType,
|
||||
problemSeason: req.body.problemSeason,
|
||||
problemEpisode: req.body.problemEpisode,
|
||||
media,
|
||||
comments: [
|
||||
new IssueComment({
|
||||
user: req.user,
|
||||
user: createdBy,
|
||||
message: req.body.message,
|
||||
}),
|
||||
],
|
||||
@@ -143,7 +160,7 @@ issueRoutes.post<
|
||||
|
||||
const newIssue = await issueRepository.save(issue);
|
||||
|
||||
return res.status(200).json(newIssue);
|
||||
return res.status(201).json(newIssue);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user