Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
updateProductionLine: jest.fn().mockResolvedValue({}),
deleteProductionLine: jest.fn().mockResolvedValue(undefined),
deleteProduction: jest.fn().mockResolvedValue(true),
hasActiveSessions: jest.fn().mockResolvedValue(false),
removeUserSession: jest.fn().mockResolvedValue('session-id'),
getUser: jest.fn().mockResolvedValue(undefined),
requireLine: jest.fn().mockResolvedValue({}),
Expand All @@ -71,12 +72,12 @@
on: jest.fn(),
once: jest.fn(),
emit: jest.fn()
} as any;

Check warning on line 75 in src/api.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

const mockIngestManager = {
load: jest.fn().mockResolvedValue(undefined),
startPolling: jest.fn()
} as any;

Check warning on line 80 in src/api.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

describe('api', () => {
it('responds with hello, world!', async () => {
Expand Down
25 changes: 25 additions & 0 deletions src/api_productions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ const mockProductionManager = {
.mockImplementation((sessionId: string) => sessionId === 'alive-session'),
deleteProductionLine: jest.fn().mockResolvedValue(undefined),
deleteProduction: jest.fn().mockResolvedValue(true),
hasActiveSessions: jest.fn().mockResolvedValue(false),
removeUserSession: jest
.fn()
.mockImplementation((sessionId: string) => sessionId),
Expand Down Expand Up @@ -463,6 +464,30 @@ describe('Production API', () => {
});
expect(response.statusCode).toBe(500);
});
test('returns 409 and does not delete when production has active sessions', async () => {
mockProductionManager.hasActiveSessions.mockResolvedValueOnce(true);
const callsBefore =
mockProductionManager.deleteProduction.mock.calls.length;
const response = await server.inject({
method: 'DELETE',
url: '/api/v1/production/1'
});
expect(response.statusCode).toBe(409);
expect(mockProductionManager.deleteProduction.mock.calls.length).toBe(
callsBefore
);
});
test('deletes the production when it has no active sessions', async () => {
mockProductionManager.hasActiveSessions.mockResolvedValueOnce(false);
const response = await server.inject({
method: 'DELETE',
url: '/api/v1/production/1'
});
expect(response.statusCode).toBe(200);
expect(mockProductionManager.deleteProduction).toHaveBeenLastCalledWith(
1
);
});
});

describe('DELETE /session/:id', () => {
Expand Down
9 changes: 9 additions & 0 deletions src/api_productions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -875,13 +875,22 @@ const apiProductions: FastifyPluginCallback<ApiProductionsOptions> = (
response: {
200: Type.String(),
400: Type.String(),
409: Type.String(),
500: Type.String()
}
}
},
async (request, reply) => {
const { productionId } = request.params;
try {
if (await productionManager.hasActiveSessions(productionId)) {
reply
.code(409)
.send(
`Cannot delete production ${productionId} with active sessions`
);
return;
}
if (
!(await productionManager.deleteProduction(
parseInt(productionId, 10)
Expand Down
1 change: 1 addition & 0 deletions src/api_re_auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const mockProductionManager = {
updateProductionLine: jest.fn().mockResolvedValue({}),
deleteProductionLine: jest.fn().mockResolvedValue(undefined),
deleteProduction: jest.fn().mockResolvedValue(true),
hasActiveSessions: jest.fn().mockResolvedValue(false),
removeUserSession: jest.fn().mockResolvedValue('session-id'),
getUser: jest.fn().mockResolvedValue(undefined),
requireLine: jest.fn().mockResolvedValue({}),
Expand Down
1 change: 1 addition & 0 deletions src/api_validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ const mockProductionManager = {
updateUserLastSeen: jest.fn().mockReturnValue(true),
deleteProductionLine: jest.fn().mockResolvedValue(undefined),
deleteProduction: jest.fn().mockResolvedValue(true),
hasActiveSessions: jest.fn().mockResolvedValue(false),
removeUserSession: jest.fn(),
createUserSession: jest.fn().mockResolvedValue(undefined),
getActiveUsers: jest.fn().mockResolvedValue([]),
Expand Down
52 changes: 52 additions & 0 deletions src/production_manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,58 @@ describe('production_manager', () => {
});
});

describe('production_manager', () => {
it('reports active sessions when the production has a non-expired active session', async () => {
const dbManager = jest.requireMock('./db/interface');
dbManager.getSessionsByQuery.mockResolvedValueOnce([
{ _id: 'session-1', productionId: '1', isActive: true, isExpired: false }
]);

const productionManagerTest = new ProductionManager(dbManager);

expect(await productionManagerTest.hasActiveSessions('1')).toStrictEqual(
true
);
expect(dbManager.getSessionsByQuery).toHaveBeenCalledWith({
productionId: '1',
isExpired: false
});
});

it('reports active sessions for a non-expired session that is no longer marked active', async () => {
// A session past SESSION_INACTIVE_THRESHOLD is flipped to isActive:false
// but stays isExpired:false until SESSION_EXPIRED_THRESHOLD. A heartbeat
// would reactivate it, so it must still block production deletion.
const dbManager = jest.requireMock('./db/interface');
dbManager.getSessionsByQuery.mockResolvedValueOnce([
{ _id: 'session-1', productionId: '1', isActive: false, isExpired: false }
]);

const productionManagerTest = new ProductionManager(dbManager);

expect(await productionManagerTest.hasActiveSessions('1')).toStrictEqual(
true
);
// The query must not require isActive:true, or it would under-block the
// inactive-but-not-expired window (#172).
expect(dbManager.getSessionsByQuery).toHaveBeenCalledWith({
productionId: '1',
isExpired: false
});
});

it('reports no active sessions when none are found', async () => {
const dbManager = jest.requireMock('./db/interface');
dbManager.getSessionsByQuery.mockResolvedValueOnce([]);

const productionManagerTest = new ProductionManager(dbManager);

expect(await productionManagerTest.hasActiveSessions('1')).toStrictEqual(
false
);
});
});

describe('production_manager', () => {
it('add an endpoint description to line connections', async () => {
const dbManager = jest.requireMock('./db/interface');
Expand Down
18 changes: 18 additions & 0 deletions src/production_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,24 @@ export class ProductionManager extends EventEmitter {
return production;
}

/**
* Returns true if the production has any non-expired session (regular
* participants or WHIP endpoints). "In use" is keyed on `isExpired: false`
* to match `getUsersForLine`/`checkUserStatus`, which drive the participant
* list and the frontend's disabled delete button. A session can be flipped
* to `isActive: false` after the inactivity threshold while still being
* non-expired (a heartbeat would reactivate it); such a session must still
* block deletion, so `isActive` is deliberately not part of this query.
* Used to guard against deleting a production that is still in use.
*/
async hasActiveSessions(productionId: string): Promise<boolean> {
const activeSessions = await this.dbManager.getSessionsByQuery({
productionId,
isExpired: false
});
return activeSessions.length > 0;
}

/**
* Delete the production from the db and local cache
*/
Expand Down
Loading