328 lines
9.4 KiB
JavaScript
328 lines
9.4 KiB
JavaScript
jest.mock('../models/Attendance', () => ({
|
|
findOne: jest.fn(),
|
|
}));
|
|
jest.mock('../models/AttendanceParticipant', () => ({
|
|
find: jest.fn(),
|
|
findOne: jest.fn(),
|
|
create: jest.fn(),
|
|
}));
|
|
jest.mock('../models/Participant', () => ({
|
|
findById: jest.fn(),
|
|
}));
|
|
jest.mock('../utils/participantMembership', () => ({
|
|
getMembership: jest.fn(),
|
|
flattenParticipantForEvent: jest.fn((p) => p),
|
|
}));
|
|
|
|
const mongoose = require('mongoose');
|
|
const Attendance = require('../models/Attendance');
|
|
const AttendanceParticipant = require('../models/AttendanceParticipant');
|
|
const Participant = require('../models/Participant');
|
|
const { getMembership } = require('../utils/participantMembership');
|
|
const { performBulkCheckInSync } = require('./attendanceCheckIn.service');
|
|
|
|
const eventId = new mongoose.Types.ObjectId().toString();
|
|
const attendanceId = new mongoose.Types.ObjectId().toString();
|
|
const participantId = new mongoose.Types.ObjectId().toString();
|
|
|
|
describe('performBulkCheckInSync', () => {
|
|
beforeEach(() => {
|
|
jest.resetAllMocks();
|
|
Attendance.findOne.mockReturnValue({
|
|
lean: () =>
|
|
Promise.resolve({
|
|
_id: attendanceId,
|
|
event: eventId,
|
|
name: 'Door A',
|
|
status: 'active',
|
|
settings: { allowDuplicateCheckIn: false },
|
|
}),
|
|
});
|
|
AttendanceParticipant.find.mockReturnValue({
|
|
select: () => ({ lean: () => Promise.resolve([]) }),
|
|
});
|
|
AttendanceParticipant.findOne.mockReset();
|
|
AttendanceParticipant.create.mockReset();
|
|
Participant.findById.mockReset();
|
|
getMembership.mockReset();
|
|
});
|
|
|
|
test('rejects empty operations', async () => {
|
|
const out = await performBulkCheckInSync({
|
|
attendanceId,
|
|
eventId,
|
|
operations: [],
|
|
checkedInBy: 'user1',
|
|
});
|
|
expect(out.err).toBeTruthy();
|
|
expect(out.statusCode).toBe(400);
|
|
});
|
|
|
|
test('rejects invalid checkedInAt', async () => {
|
|
AttendanceParticipant.find.mockReturnValue({
|
|
select: () => ({ lean: () => Promise.resolve([]) }),
|
|
});
|
|
const out = await performBulkCheckInSync({
|
|
attendanceId,
|
|
eventId,
|
|
checkedInBy: 'user1',
|
|
operations: [
|
|
{
|
|
clientOpId: 'op-1',
|
|
participantId,
|
|
checkedInAt: 'not-a-date',
|
|
},
|
|
],
|
|
});
|
|
expect(out.results[0].status).toBe('rejected');
|
|
expect(out.results[0].message).toMatch(/checkedInAt/i);
|
|
});
|
|
|
|
test('returns duplicate for existing clientOpId', async () => {
|
|
AttendanceParticipant.find.mockReturnValue({
|
|
select: () => ({
|
|
lean: () =>
|
|
Promise.resolve([
|
|
{
|
|
participant: participantId,
|
|
checkIns: [
|
|
{
|
|
clientOpId: 'op-dup',
|
|
checkedInAt: new Date('2026-08-01T10:00:00.000Z'),
|
|
},
|
|
],
|
|
},
|
|
]),
|
|
}),
|
|
});
|
|
|
|
const out = await performBulkCheckInSync({
|
|
attendanceId,
|
|
eventId,
|
|
checkedInBy: 'user1',
|
|
operations: [
|
|
{
|
|
clientOpId: 'op-dup',
|
|
participantId,
|
|
checkedInAt: '2026-08-01T10:00:00.000Z',
|
|
},
|
|
],
|
|
});
|
|
expect(out.results[0].status).toBe('duplicate');
|
|
});
|
|
|
|
test('conflicts when already checked in and duplicates disallowed', async () => {
|
|
AttendanceParticipant.find.mockReturnValue({
|
|
select: () => ({ lean: () => Promise.resolve([]) }),
|
|
});
|
|
getMembership.mockReturnValue({ checkedIn: false });
|
|
Participant.findById.mockResolvedValue({
|
|
_id: participantId,
|
|
firstName: 'Ada',
|
|
lastName: 'Lovelace',
|
|
email: 'ada@example.com',
|
|
phone: '',
|
|
toObject: () => ({}),
|
|
markModified: jest.fn(),
|
|
save: jest.fn(),
|
|
});
|
|
AttendanceParticipant.findOne
|
|
.mockResolvedValueOnce({
|
|
checkIns: [{ checkedInAt: new Date('2026-08-01T09:00:00.000Z') }],
|
|
})
|
|
.mockReturnValueOnce({
|
|
select: () => ({ lean: () => Promise.resolve(null) }),
|
|
});
|
|
|
|
const out = await performBulkCheckInSync({
|
|
attendanceId,
|
|
eventId,
|
|
checkedInBy: 'user1',
|
|
operations: [
|
|
{
|
|
clientOpId: 'op-conflict',
|
|
participantId,
|
|
checkedInAt: '2026-08-01T11:00:00.000Z',
|
|
},
|
|
],
|
|
});
|
|
expect(out.results[0].status).toBe('conflict');
|
|
});
|
|
|
|
test('applies offline check-in with client timestamp', async () => {
|
|
AttendanceParticipant.find.mockReturnValue({
|
|
select: () => ({ lean: () => Promise.resolve([]) }),
|
|
});
|
|
getMembership.mockReturnValue({ checkedIn: false });
|
|
const participantDoc = {
|
|
_id: participantId,
|
|
firstName: 'Ada',
|
|
lastName: 'Lovelace',
|
|
email: 'ada@example.com',
|
|
phone: '',
|
|
toObject: () => ({}),
|
|
markModified: jest.fn(),
|
|
save: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
Participant.findById.mockResolvedValue(participantDoc);
|
|
AttendanceParticipant.findOne
|
|
.mockResolvedValueOnce(null)
|
|
.mockReturnValueOnce({
|
|
select: () => ({ lean: () => Promise.resolve(null) }),
|
|
});
|
|
const created = {
|
|
checkIns: [
|
|
{
|
|
clientOpId: 'op-new',
|
|
checkedInAt: new Date('2026-08-01T11:00:00.000Z'),
|
|
source: 'offline_sync',
|
|
},
|
|
],
|
|
};
|
|
AttendanceParticipant.create.mockResolvedValue(created);
|
|
|
|
const out = await performBulkCheckInSync({
|
|
attendanceId,
|
|
eventId,
|
|
checkedInBy: 'user1',
|
|
deviceId: 'device-1',
|
|
operations: [
|
|
{
|
|
clientOpId: 'op-new',
|
|
participantId,
|
|
checkedInAt: '2026-08-01T11:00:00.000Z',
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(out.results[0].status).toBe('applied');
|
|
expect(AttendanceParticipant.create).toHaveBeenCalled();
|
|
const entry = AttendanceParticipant.create.mock.calls[0][0].checkIns[0];
|
|
expect(entry.source).toBe('offline_sync');
|
|
expect(entry.clientOpId).toBe('op-new');
|
|
expect(new Date(entry.checkedInAt).toISOString()).toBe('2026-08-01T11:00:00.000Z');
|
|
});
|
|
|
|
test('rejects assign-blank profile when setting is off', async () => {
|
|
AttendanceParticipant.find.mockReturnValue({
|
|
select: () => ({ lean: () => Promise.resolve([]) }),
|
|
});
|
|
const out = await performBulkCheckInSync({
|
|
attendanceId,
|
|
eventId,
|
|
checkedInBy: 'user1',
|
|
operations: [
|
|
{
|
|
clientOpId: 'op-blank',
|
|
participantId,
|
|
checkedInAt: '2026-08-01T11:00:00.000Z',
|
|
profile: { firstName: 'Walk', lastName: 'In', email: '', phone: '' },
|
|
},
|
|
],
|
|
});
|
|
expect(out.results[0].status).toBe('rejected');
|
|
expect(out.results[0].message).toMatch(/Assign a Blank/i);
|
|
});
|
|
|
|
test('applies undo by popping last check-in', async () => {
|
|
AttendanceParticipant.find.mockReturnValue({
|
|
select: () => ({ lean: () => Promise.resolve([]) }),
|
|
});
|
|
getMembership.mockReturnValue({ checkedIn: true });
|
|
Participant.findById.mockResolvedValue({
|
|
_id: participantId,
|
|
firstName: 'Ada',
|
|
lastName: 'Lovelace',
|
|
email: 'ada@example.com',
|
|
phone: '',
|
|
});
|
|
const apDoc = {
|
|
checkIns: [
|
|
{ checkedInAt: new Date('2026-08-01T09:00:00.000Z'), clientOpId: 'op-in' },
|
|
],
|
|
undos: [],
|
|
save: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
AttendanceParticipant.findOne.mockResolvedValue(apDoc);
|
|
|
|
const out = await performBulkCheckInSync({
|
|
attendanceId,
|
|
eventId,
|
|
checkedInBy: 'user1',
|
|
operations: [
|
|
{
|
|
clientOpId: 'op-undo',
|
|
participantId,
|
|
checkedInAt: '2026-08-01T11:00:00.000Z',
|
|
action: 'undo',
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(out.results[0].status).toBe('applied');
|
|
expect(out.results[0].checkInCount).toBe(0);
|
|
expect(apDoc.checkIns).toHaveLength(0);
|
|
expect(apDoc.undos[0].clientOpId).toBe('op-undo');
|
|
expect(apDoc.save).toHaveBeenCalled();
|
|
});
|
|
|
|
test('returns duplicate for existing undo clientOpId', async () => {
|
|
AttendanceParticipant.find.mockReturnValue({
|
|
select: () => ({
|
|
lean: () =>
|
|
Promise.resolve([
|
|
{
|
|
participant: participantId,
|
|
checkIns: [],
|
|
undos: [{ clientOpId: 'op-undo-dup', undoneAt: new Date() }],
|
|
},
|
|
]),
|
|
}),
|
|
});
|
|
|
|
const out = await performBulkCheckInSync({
|
|
attendanceId,
|
|
eventId,
|
|
checkedInBy: 'user1',
|
|
operations: [
|
|
{
|
|
clientOpId: 'op-undo-dup',
|
|
participantId,
|
|
checkedInAt: '2026-08-01T11:00:00.000Z',
|
|
action: 'undo',
|
|
},
|
|
],
|
|
});
|
|
expect(out.results[0].status).toBe('duplicate');
|
|
});
|
|
|
|
test('conflicts undo when participant is not checked in', async () => {
|
|
AttendanceParticipant.find.mockReturnValue({
|
|
select: () => ({ lean: () => Promise.resolve([]) }),
|
|
});
|
|
getMembership.mockReturnValue({ checkedIn: false });
|
|
Participant.findById.mockResolvedValue({
|
|
_id: participantId,
|
|
firstName: 'Ada',
|
|
lastName: 'Lovelace',
|
|
});
|
|
AttendanceParticipant.findOne.mockResolvedValue({ checkIns: [], undos: [] });
|
|
|
|
const out = await performBulkCheckInSync({
|
|
attendanceId,
|
|
eventId,
|
|
checkedInBy: 'user1',
|
|
operations: [
|
|
{
|
|
clientOpId: 'op-undo-empty',
|
|
participantId,
|
|
checkedInAt: '2026-08-01T11:00:00.000Z',
|
|
action: 'undo',
|
|
},
|
|
],
|
|
});
|
|
expect(out.results[0].status).toBe('conflict');
|
|
expect(out.results[0].message).toMatch(/not checked in/i);
|
|
});
|
|
});
|