diff --git a/backend/src/__tests__/adminOnly.middleware.test.js b/backend/src/__tests__/adminOnly.middleware.test.js new file mode 100644 index 0000000..e3ad912 --- /dev/null +++ b/backend/src/__tests__/adminOnly.middleware.test.js @@ -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); + }); +}); diff --git a/backend/src/__tests__/queue.controller.test.js b/backend/src/__tests__/queue.controller.test.js new file mode 100644 index 0000000..882c4da --- /dev/null +++ b/backend/src/__tests__/queue.controller.test.js @@ -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' }) + ); + }); +}); diff --git a/backend/src/__tests__/queue.routes.test.js b/backend/src/__tests__/queue.routes.test.js new file mode 100644 index 0000000..a72ea39 --- /dev/null +++ b/backend/src/__tests__/queue.routes.test.js @@ -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(); + }); + }); +}); diff --git a/backend/src/app.js b/backend/src/app.js index c330bc3..aa55c7e 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -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()); @@ -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) => { diff --git a/backend/src/controllers/queue.controller.js b/backend/src/controllers/queue.controller.js new file mode 100644 index 0000000..00d560f --- /dev/null +++ b/backend/src/controllers/queue.controller.js @@ -0,0 +1,136 @@ +const { Ticket, Department, sequelize } = require('../models'); + +// ========================================== +// GET CURRENT SERVING TICKET +// ========================================== +const getCurrentServing = async (req, res) => { + try { + const { departmentId } = req.params; + + const department = await Department.findByPk(departmentId); + if (!department) { + return res.status(404).json({ + success: false, + message: 'Department not found', + }); + } + + const ticket = await Ticket.findOne({ + where: { departmentId, status: 'serving' }, + order: [['updatedAt', 'DESC']], + }); + + return res.status(200).json({ + success: true, + ticket: ticket || null, + }); + } catch (error) { + console.error('Get current serving error:', error); + return res.status(500).json({ + success: false, + message: 'Server error', + }); + } +}; + +// ========================================== +// MOVE NEXT WAITING TICKET TO SERVING +// ========================================== +const callNext = async (req, res) => { + try { + const { departmentId } = req.params; + + const department = await Department.findByPk(departmentId); + if (!department) { + return res.status(404).json({ + success: false, + message: 'Department not found', + }); + } + + const nextTicket = await sequelize.transaction(async (t) => { + // Find next waiting ticket first + const ticket = await Ticket.findOne({ + where: { departmentId, status: 'waiting' }, + order: [['createdAt', 'ASC']], + transaction: t, + lock: t.LOCK.UPDATE, + skipLocked: true, + }); + if (!ticket) return null; + + // Only complete current serving ticket if we have a replacement + const currentServing = await Ticket.findOne({ + where: { departmentId, status: 'serving' }, + transaction: t, + lock: t.LOCK.UPDATE, + }); + if (currentServing) { + currentServing.status = 'completed'; + await currentServing.save({ transaction: t }); + } + + ticket.status = 'serving'; + await ticket.save({ transaction: t }); + return ticket; + }); + + if (!nextTicket) { + return res.status(404).json({ + success: false, + message: 'No waiting tickets', + }); + } + + return res.status(200).json({ + success: true, + message: 'Next ticket is now being served', + ticket: nextTicket, + }); + } catch (error) { + console.error('Call next error:', error); + return res.status(500).json({ + success: false, + message: 'Server error', + }); + } +}; + +// ========================================== +// GET WAITING TICKETS +// ========================================== +const getWaiting = async (req, res) => { + try { + const { departmentId } = req.params; + + const department = await Department.findByPk(departmentId); + if (!department) { + return res.status(404).json({ + success: false, + message: 'Department not found', + }); + } + + const tickets = await Ticket.findAll({ + where: { departmentId, status: 'waiting' }, + order: [['createdAt', 'ASC']], + }); + + return res.status(200).json({ + success: true, + tickets, + }); + } catch (error) { + console.error('Get waiting error:', error); + return res.status(500).json({ + success: false, + message: 'Server error', + }); + } +}; + +module.exports = { + getCurrentServing, + callNext, + getWaiting, +}; diff --git a/backend/src/middleware/adminOnly.js b/backend/src/middleware/adminOnly.js new file mode 100644 index 0000000..1dfd337 --- /dev/null +++ b/backend/src/middleware/adminOnly.js @@ -0,0 +1,11 @@ +const adminOnly = (req, res, next) => { + if (!req.user || req.user.role !== 'admin') { + return res.status(403).json({ + success: false, + message: 'Access denied. Admin only.', + }); + } + next(); +}; + +module.exports = adminOnly; diff --git a/backend/src/routes/queue.routes.js b/backend/src/routes/queue.routes.js new file mode 100644 index 0000000..027d660 --- /dev/null +++ b/backend/src/routes/queue.routes.js @@ -0,0 +1,19 @@ +const express = require('express'); +const router = express.Router(); +const authenticate = require('../middleware/auth'); +const adminOnly = require('../middleware/adminOnly'); +const { getCurrentServing, callNext, getWaiting } = require('../controllers/queue.controller'); + +// All routes require authentication +router.use(authenticate); + +// GET /api/queues/:departmentId/current +router.get('/:departmentId/current', getCurrentServing); + +// POST /api/queues/:departmentId/next (admin only) +router.post('/:departmentId/next', adminOnly, callNext); + +// GET /api/queues/:departmentId/waiting +router.get('/:departmentId/waiting', getWaiting); + +module.exports = router;