Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions server/subscriber/MediaSubscriber.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

import {
MediaRequestStatus,
MediaStatus,
MediaType,
} from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest';
import { User } from '@server/entity/User';
import { setupTestDb } from '@server/test/db';

setupTestDb();

async function createMedia(
status: MediaStatus,
mediaType = MediaType.MOVIE,
status4k = MediaStatus.UNKNOWN
): Promise<Media> {
const mediaRepo = getRepository(Media);
const media = new Media();
media.tmdbId = Math.floor(Math.random() * 900000) + 100000;
media.mediaType = mediaType;
media.status = status;
media.status4k = status4k;
return mediaRepo.save(media);
}

async function createMovieRequest(
requestedBy: User,
media: Media,
status: MediaRequestStatus,
is4k = false
): Promise<MediaRequest> {
const requestRepo = getRepository(MediaRequest);
const req = new MediaRequest({
media,
requestedBy,
status,
type: MediaType.MOVIE,
is4k,
});
return requestRepo.save(req);
}

describe('MediaSubscriber', () => {
/**
* Tests the beforeUpdate path: when media transitions PENDING → AVAILABLE,
* updateChildRequestStatus must be awaited so that PENDING requests are
* promoted to APPROVED before the save completes. afterUpdate then sees
* those APPROVED requests and marks them COMPLETED.
*
* Without await on updateChildRequestStatus, afterUpdate may read the
* request before the status change is committed and leave it PENDING.
*/
it('processes a PENDING request to COMPLETED when media transitions PENDING -> AVAILABLE', async () => {
const userRepo = getRepository(User);
const mediaRepo = getRepository(Media);
const requestRepo = getRepository(MediaRequest);

const user = await userRepo.findOneOrFail({
where: { email: 'admin@seerr.dev' },
});

const media = await createMedia(MediaStatus.PENDING);
const mediaRequest = await createMovieRequest(
user,
media,
MediaRequestStatus.PENDING
);

// beforeUpdate auto-approves PENDING request; afterUpdate marks it COMPLETED
media.status = MediaStatus.AVAILABLE;
await mediaRepo.save(media);

const reloaded = await requestRepo.findOneOrFail({
where: { id: mediaRequest.id },
});
assert.strictEqual(reloaded.status, MediaRequestStatus.COMPLETED);
});

it('processes a PENDING 4K request to COMPLETED when media 4K status transitions PENDING -> AVAILABLE', async () => {
const userRepo = getRepository(User);
const mediaRepo = getRepository(Media);
const requestRepo = getRepository(MediaRequest);

const user = await userRepo.findOneOrFail({
where: { email: 'admin@seerr.dev' },
});

// status stays UNKNOWN; only status4k goes PENDING → AVAILABLE
const media = await createMedia(
MediaStatus.UNKNOWN,
MediaType.MOVIE,
MediaStatus.PENDING
);
const mediaRequest = await createMovieRequest(
user,
media,
MediaRequestStatus.PENDING,
true
);

media.status4k = MediaStatus.AVAILABLE;
await mediaRepo.save(media);

const reloaded = await requestRepo.findOneOrFail({
where: { id: mediaRequest.id },
});
assert.strictEqual(reloaded.status, MediaRequestStatus.COMPLETED);
});

/**
* Tests the afterUpdate path: when media transitions PROCESSING → AVAILABLE,
* updateRelatedMediaRequest must be awaited so that APPROVED requests are
* marked COMPLETED before the save returns.
*/
it('marks an APPROVED request COMPLETED when media transitions PROCESSING -> AVAILABLE', async () => {
const userRepo = getRepository(User);
const mediaRepo = getRepository(Media);
const requestRepo = getRepository(MediaRequest);

const user = await userRepo.findOneOrFail({
where: { email: 'admin@seerr.dev' },
});

// PROCESSING means beforeUpdate does not fire (was not PENDING)
const media = await createMedia(MediaStatus.PROCESSING);
const mediaRequest = await createMovieRequest(
user,
media,
MediaRequestStatus.APPROVED
);

media.status = MediaStatus.AVAILABLE;
await mediaRepo.save(media);

const reloaded = await requestRepo.findOneOrFail({
where: { id: mediaRequest.id },
});
assert.strictEqual(reloaded.status, MediaRequestStatus.COMPLETED);
});

it('marks an APPROVED 4K request COMPLETED when media 4K status transitions PROCESSING -> AVAILABLE', async () => {
const userRepo = getRepository(User);
const mediaRepo = getRepository(Media);
const requestRepo = getRepository(MediaRequest);

const user = await userRepo.findOneOrFail({
where: { email: 'admin@seerr.dev' },
});

const media = await createMedia(
MediaStatus.UNKNOWN,
MediaType.MOVIE,
MediaStatus.PROCESSING
);
const mediaRequest = await createMovieRequest(
user,
media,
MediaRequestStatus.APPROVED,
true
);

media.status4k = MediaStatus.AVAILABLE;
await mediaRepo.save(media);

const reloaded = await requestRepo.findOneOrFail({
where: { id: mediaRequest.id },
});
assert.strictEqual(reloaded.status, MediaRequestStatus.COMPLETED);
});

it('marks a FAILED request COMPLETED when media transitions PROCESSING -> AVAILABLE', async () => {
const userRepo = getRepository(User);
const mediaRepo = getRepository(Media);
const requestRepo = getRepository(MediaRequest);

const user = await userRepo.findOneOrFail({
where: { email: 'admin@seerr.dev' },
});

const media = await createMedia(MediaStatus.PROCESSING);
const mediaRequest = await createMovieRequest(
user,
media,
MediaRequestStatus.FAILED
);

media.status = MediaStatus.AVAILABLE;
await mediaRepo.save(media);

const reloaded = await requestRepo.findOneOrFail({
where: { id: mediaRequest.id },
});
assert.strictEqual(reloaded.status, MediaRequestStatus.COMPLETED);
});
});
48 changes: 36 additions & 12 deletions server/subscriber/MediaSubscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,55 @@ import {
MediaStatus,
MediaType,
} from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest';
import Season from '@server/entity/Season';
import SeasonRequest from '@server/entity/SeasonRequest';
import type { EntitySubscriberInterface, UpdateEvent } from 'typeorm';
import type {
EntityManager,
EntitySubscriberInterface,
UpdateEvent,
} from 'typeorm';
import { EventSubscriber, In } from 'typeorm';

@EventSubscriber()
export class MediaSubscriber implements EntitySubscriberInterface<Media> {
private async updateChildRequestStatus(event: Media, is4k: boolean) {
const requestRepository = getRepository(MediaRequest);
private async updateChildRequestStatus(
manager: EntityManager,
media: Media,
is4k: boolean
) {
const requestRepository = manager.getRepository(MediaRequest);

const requests = await requestRepository.find({
where: { media: { id: event.id } },
where: { media: { id: media.id } },
});

const toSave: MediaRequest[] = [];

for (const request of requests) {
if (
request.is4k === is4k &&
request.status === MediaRequestStatus.PENDING
) {
request.status = MediaRequestStatus.APPROVED;
await requestRepository.save(request);
toSave.push(request);
}
}

if (toSave.length > 0) {
await requestRepository.save(toSave);
}
}

private async updateRelatedMediaRequest(
manager: EntityManager,
event: Media,
databaseEvent: Media,
is4k: boolean
) {
const requestRepository = getRepository(MediaRequest);
const seasonRequestRepository = getRepository(SeasonRequest);
const requestRepository = manager.getRepository(MediaRequest);
const seasonRequestRepository = manager.getRepository(SeasonRequest);

const relatedRequests = await requestRepository.find({
relations: {
Expand Down Expand Up @@ -130,14 +144,22 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
event.entity.status === MediaStatus.AVAILABLE &&
event.databaseEntity.status === MediaStatus.PENDING
) {
this.updateChildRequestStatus(event.entity as Media, false);
await this.updateChildRequestStatus(
event.manager,
event.entity as Media,
false
);
}

if (
event.entity.status4k === MediaStatus.AVAILABLE &&
event.databaseEntity.status4k === MediaStatus.PENDING
) {
this.updateChildRequestStatus(event.entity as Media, true);
await this.updateChildRequestStatus(
event.manager,
event.entity as Media,
true
);
}

// Manually load related seasons into databaseEntity
Expand Down Expand Up @@ -180,7 +202,8 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
seasonStatusCheck(false))) &&
validStatuses.includes(event.entity.status)
) {
this.updateRelatedMediaRequest(
await this.updateRelatedMediaRequest(
event.manager,
event.entity as Media,
event.databaseEntity as Media,
false
Expand All @@ -192,7 +215,8 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
(event.entity.mediaType === MediaType.TV && seasonStatusCheck(true))) &&
validStatuses.includes(event.entity.status4k)
) {
this.updateRelatedMediaRequest(
await this.updateRelatedMediaRequest(
event.manager,
event.entity as Media,
event.databaseEntity as Media,
true
Expand Down
Loading