Skip to content
Open
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
44 changes: 44 additions & 0 deletions backend/src/__tests__/adminOnly.middleware.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const adminOnly = require('../middleware/adminOnly');

function mockReqRes(user) {
const req = { user };
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
const next = jest.fn();
return { req, res, next };
}

describe('adminOnly middleware', () => {
it('calls next() when user is admin', () => {
const { req, res, next } = mockReqRes({ id: 'u1', role: 'admin' });
adminOnly(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});

it('returns 403 when user role is not admin', () => {
const { req, res, next } = mockReqRes({ id: 'u1', role: 'user' });
adminOnly(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ success: false, message: 'Access denied. Admin only.' })
);
});

it('returns 403 when req.user is undefined', () => {
const { req, res, next } = mockReqRes(undefined);
adminOnly(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});

it('returns 403 when req.user has no role', () => {
const { req, res, next } = mockReqRes({ id: 'u1' });
adminOnly(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
});
243 changes: 243 additions & 0 deletions backend/src/__tests__/queue.controller.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
const { getCurrentServing, callNext, getWaiting } = require('../controllers/queue.controller');

jest.mock('../models', () => {
const mockTicket = {
findOne: jest.fn(),
findAll: jest.fn(),
};
const mockDepartment = {
findByPk: jest.fn(),
};
const mockSequelize = {
transaction: jest.fn((cb) => {
const fakeTransaction = { LOCK: { UPDATE: 'UPDATE' } };
return cb(fakeTransaction);
}),
};
return { Ticket: mockTicket, Department: mockDepartment, sequelize: mockSequelize };
});

const { Ticket, Department, sequelize } = require('../models');

function mockReqRes(params = {}, user = { id: 'user-1', role: 'admin' }) {
const req = { params, user };
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
return { req, res };
}

// ── GET CURRENT SERVING ─────────────────────────────────────────────────────

describe('getCurrentServing', () => {
beforeEach(() => jest.clearAllMocks());

it('returns 404 when department does not exist', async () => {
Department.findByPk.mockResolvedValue(null);
const { req, res } = mockReqRes({ departmentId: 'dept-1' });
await getCurrentServing(req, res);
expect(Department.findByPk).toHaveBeenCalledWith('dept-1');
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Department not found' })
);
});

it('returns 200 with serving ticket when one exists', async () => {
Department.findByPk.mockResolvedValue({ id: 'dept-1' });
const fakeTicket = { id: 't1', status: 'serving', departmentId: 'dept-1' };
Ticket.findOne.mockResolvedValue(fakeTicket);

const { req, res } = mockReqRes({ departmentId: 'dept-1' });
await getCurrentServing(req, res);

expect(Ticket.findOne).toHaveBeenCalledWith({
where: { departmentId: 'dept-1', status: 'serving' },
order: [['updatedAt', 'DESC']],
});
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ success: true, ticket: fakeTicket })
);
});

it('returns 200 with null ticket when none serving', async () => {
Department.findByPk.mockResolvedValue({ id: 'dept-1' });
Ticket.findOne.mockResolvedValue(null);

const { req, res } = mockReqRes({ departmentId: 'dept-1' });
await getCurrentServing(req, res);

expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ success: true, ticket: null })
);
});

it('returns 500 on unexpected error', async () => {
Department.findByPk.mockRejectedValue(new Error('DB down'));
const { req, res } = mockReqRes({ departmentId: 'dept-1' });
await getCurrentServing(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ success: false, message: 'Server error' })
);
});
});

// ── CALL NEXT ───────────────────────────────────────────────────────────────

describe('callNext', () => {
beforeEach(() => jest.clearAllMocks());

it('returns 404 when department does not exist', async () => {
Department.findByPk.mockResolvedValue(null);
const { req, res } = mockReqRes({ departmentId: 'dept-1' });
await callNext(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Department not found' })
);
});

it('returns 404 when no waiting tickets', async () => {
Department.findByPk.mockResolvedValue({ id: 'dept-1' });
// First call: no currently serving ticket; Second call: no waiting ticket
Ticket.findOne.mockResolvedValue(null);

const { req, res } = mockReqRes({ departmentId: 'dept-1' });
await callNext(req, res);

expect(sequelize.transaction).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ message: 'No waiting tickets' })
);
});

it('returns 200 and moves next waiting ticket to serving', async () => {
Department.findByPk.mockResolvedValue({ id: 'dept-1' });
const fakeTicket = {
id: 't1',
status: 'waiting',
departmentId: 'dept-1',
save: jest.fn().mockResolvedValue(true),
};
// First findOne: next waiting ticket; Second findOne: no currently serving
Ticket.findOne
.mockResolvedValueOnce(fakeTicket)
.mockResolvedValueOnce(null);

const { req, res } = mockReqRes({ departmentId: 'dept-1' });
await callNext(req, res);

expect(fakeTicket.status).toBe('serving');
expect(fakeTicket.save).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: true,
message: 'Next ticket is now being served',
ticket: fakeTicket,
})
);
});

it('completes the currently serving ticket before promoting next', async () => {
Department.findByPk.mockResolvedValue({ id: 'dept-1' });
const currentTicket = {
id: 't0',
status: 'serving',
save: jest.fn().mockResolvedValue(true),
};
const nextTicket = {
id: 't1',
status: 'waiting',
save: jest.fn().mockResolvedValue(true),
};
// First findOne: next waiting ticket; Second findOne: currently serving
Ticket.findOne
.mockResolvedValueOnce(nextTicket)
.mockResolvedValueOnce(currentTicket);

const { req, res } = mockReqRes({ departmentId: 'dept-1' });
await callNext(req, res);

expect(currentTicket.status).toBe('completed');
expect(currentTicket.save).toHaveBeenCalled();
expect(nextTicket.status).toBe('serving');
expect(nextTicket.save).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
});

it('returns 500 on unexpected error', async () => {
Department.findByPk.mockRejectedValue(new Error('DB down'));
const { req, res } = mockReqRes({ departmentId: 'dept-1' });
await callNext(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ success: false, message: 'Server error' })
);
});
});

// ── GET WAITING ─────────────────────────────────────────────────────────────

describe('getWaiting', () => {
beforeEach(() => jest.clearAllMocks());

it('returns 404 when department does not exist', async () => {
Department.findByPk.mockResolvedValue(null);
const { req, res } = mockReqRes({ departmentId: 'dept-1' });
await getWaiting(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Department not found' })
);
});

it('returns 200 with waiting tickets', async () => {
Department.findByPk.mockResolvedValue({ id: 'dept-1' });
const fakeTickets = [
{ id: 't1', status: 'waiting' },
{ id: 't2', status: 'waiting' },
];
Ticket.findAll.mockResolvedValue(fakeTickets);

const { req, res } = mockReqRes({ departmentId: 'dept-1' });
await getWaiting(req, res);

expect(Ticket.findAll).toHaveBeenCalledWith({
where: { departmentId: 'dept-1', status: 'waiting' },
order: [['createdAt', 'ASC']],
});
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ success: true, tickets: fakeTickets })
);
});

it('returns empty array when no waiting tickets', async () => {
Department.findByPk.mockResolvedValue({ id: 'dept-1' });
Ticket.findAll.mockResolvedValue([]);

const { req, res } = mockReqRes({ departmentId: 'dept-1' });
await getWaiting(req, res);

expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ success: true, tickets: [] })
);
});

it('returns 500 on unexpected error', async () => {
Department.findByPk.mockRejectedValue(new Error('DB down'));
const { req, res } = mockReqRes({ departmentId: 'dept-1' });
await getWaiting(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ success: false, message: 'Server error' })
);
});
});
80 changes: 80 additions & 0 deletions backend/src/__tests__/queue.routes.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
const request = require('supertest');
const jwt = require('jsonwebtoken');

process.env.JWT_SECRET = 'test-secret';

const app = require('../app');

jest.mock('../controllers/queue.controller', () => ({
getCurrentServing: jest.fn((req, res) =>
res.status(200).json({ success: true, ticket: null })
),
callNext: jest.fn((req, res) =>
res.status(200).json({ success: true, message: 'Next ticket is now being served' })
),
getWaiting: jest.fn((req, res) =>
res.status(200).json({ success: true, tickets: [] })
),
}));

const { getCurrentServing, callNext, getWaiting } = require('../controllers/queue.controller');

const adminToken = jwt.sign({ id: 'admin-1', role: 'admin' }, process.env.JWT_SECRET || 'test-secret');
const userToken = jwt.sign({ id: 'user-1', role: 'user' }, process.env.JWT_SECRET || 'test-secret');

describe('Queue routes', () => {
beforeEach(() => jest.clearAllMocks());

describe('GET /api/queues/:departmentId/current', () => {
it('returns 401 without token', async () => {
const res = await request(app).get('/api/queues/dept-1/current');
expect(res.status).toBe(401);
});

it('routes to getCurrentServing with valid token', async () => {
const res = await request(app)
.get('/api/queues/dept-1/current')
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(200);
expect(getCurrentServing).toHaveBeenCalled();
});
});

describe('POST /api/queues/:departmentId/next', () => {
it('returns 401 without token', async () => {
const res = await request(app).post('/api/queues/dept-1/next');
expect(res.status).toBe(401);
});

it('returns 403 for non-admin user', async () => {
const res = await request(app)
.post('/api/queues/dept-1/next')
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(403);
expect(callNext).not.toHaveBeenCalled();
});

it('routes to callNext for admin user', async () => {
const res = await request(app)
.post('/api/queues/dept-1/next')
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
expect(callNext).toHaveBeenCalled();
});
});

describe('GET /api/queues/:departmentId/waiting', () => {
it('returns 401 without token', async () => {
const res = await request(app).get('/api/queues/dept-1/waiting');
expect(res.status).toBe(401);
});

it('routes to getWaiting with valid token', async () => {
const res = await request(app)
.get('/api/queues/dept-1/waiting')
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(200);
expect(getWaiting).toHaveBeenCalled();
});
});
});
2 changes: 2 additions & 0 deletions backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const app = express();

const authRoutes = require('./routes/auth.routes');
const ticketRoutes = require('./routes/ticket.routes');
const queueRoutes = require('./routes/queue.routes');

// Global Middleware
app.use(cors());
Expand All @@ -14,6 +15,7 @@ app.use(morgan('dev'));
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/tickets', ticketRoutes);
app.use('/api/queues', queueRoutes);

// Health Check
app.get('/api/health', (req, res) => {
Expand Down
Loading