@lilaquadrat/studio 12.3.0 → 13.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -170,6 +170,40 @@ describe('shift planning', () => {
170
170
  const cleared = await plansService.changePlan(created._id, { name: created.name, timeZone: created.timeZone, settingsOverrides: {} }, options);
171
171
  expect(cleared.list).toBeUndefined();
172
172
  });
173
+ it.each(['replace', 'remove'])('refuses to %s the employee list until existing assignments are cleared', async (change) => {
174
+ const plan = await setupPlan();
175
+ const otherListId = new ObjectId();
176
+ await ListsModel.db.insertOne({ ...list, _id: otherListId, name: 'Events' });
177
+ await shiftsService.createShift(plan._id, shiftInput(plan, housekeepingAreaId, { assignments: [] }), options);
178
+ const shift = await shiftsService.createShift(plan._id, shiftInput(plan, receptionAreaId), options);
179
+ const previousPlan = await ShiftPlanModel.db.findOne({ _id: plan._id });
180
+ const previousShift = await ShiftModel.db.findOne({ _id: shift._id });
181
+ const historyCount = await HistoryModel.db.countDocuments({});
182
+ const input = change === 'replace' ? planInput(otherListId) : planInput(listId, { list: undefined });
183
+ await expect(plansService.changePlan(plan._id, input, options)).rejects.toThrow('SHIFT_PLAN_REFERENCES_IN_USE');
184
+ expect(await ShiftPlanModel.db.findOne({ _id: plan._id })).toEqual(previousPlan);
185
+ expect(await ShiftModel.db.findOne({ _id: shift._id })).toEqual(previousShift);
186
+ expect(await HistoryModel.db.countDocuments({})).toBe(historyCount);
187
+ const cleared = await shiftsService.replaceAssignments(plan._id, shift._id, { assignments: [] }, options);
188
+ expect(cleared.assignments).toEqual([]);
189
+ const changed = await plansService.changePlan(plan._id, input, options);
190
+ expect(changed.list?.toHexString()).toBe(input.list?.toHexString());
191
+ });
192
+ it('does not let assignments in another plan, project or company prevent an employee-list change', async () => {
193
+ const plan = await setupPlan();
194
+ const shift = await shiftsService.createShift(plan._id, shiftInput(plan, housekeepingAreaId, { assignments: [] }), options);
195
+ const document = (await ShiftModel.db.findOne({ _id: shift._id }));
196
+ const assignments = [{ participant: participantId, customer: customerId, breaks: [] }];
197
+ await ShiftModel.db.insertMany([
198
+ { ...document, _id: new ObjectId(), plan: new ObjectId(), assignments },
199
+ { ...document, _id: new ObjectId(), project: 'foreign', assignments },
200
+ { ...document, _id: new ObjectId(), company: 'foreign', assignments },
201
+ ]);
202
+ const changed = await plansService.changePlan(plan._id, planInput(listId, { list: undefined }), options);
203
+ expect(changed.list).toBeUndefined();
204
+ expect(changed.shifts).toHaveLength(1);
205
+ expect(changed.shifts[0]._id.equals(shift._id)).toBe(true);
206
+ });
173
207
  it('resolves assignments against the linked plan list for a shift persisted before the link', async () => {
174
208
  await settingsService.changeSettings({ settings: policy() }, options);
175
209
  const created = await plansService.createPlan({ name: 'Unlinked', timeZone: 'Europe/Berlin', settingsOverrides: {} }, options);
@@ -209,11 +243,46 @@ describe('shift planning', () => {
209
243
  const bound = await plansService.changePlan(plan._id, planInput(listId, { workAreaCategories: [{ area, categoryIds: [categoryId] }] }), options);
210
244
  expect(bound.workAreaCategories).toHaveLength(1);
211
245
  expect(bound.workAreaCategories[0].categoryIds[0].equals(categoryId)).toBe(true);
246
+ const kept = await plansService.changePlan(plan._id, planInput(new ObjectId(listId.toHexString()), { name: 'Renamed' }), options);
247
+ expect(kept.workAreaCategories).toEqual(bound.workAreaCategories);
212
248
  const cleared = await plansService.changePlan(plan._id, planInput(listId, { workAreaCategories: [] }), options);
213
249
  expect(cleared.workAreaCategories).toEqual([]);
214
250
  const untouched = await plansService.changePlan(plan._id, planInput(listId), options);
215
251
  expect(untouched.workAreaCategories).toEqual([]);
216
252
  });
253
+ it.each(['replace', 'remove'])('clears omitted category bindings when an unassigned plan changes its list through %s', async (change) => {
254
+ const plan = await setupPlan();
255
+ await plansService.changePlan(plan._id, planInput(listId, {
256
+ workAreaCategories: [{ area: housekeepingAreaId, categoryIds: [categoryId] }],
257
+ }), options);
258
+ await shiftsService.createShift(plan._id, shiftInput(plan, housekeepingAreaId, { assignments: [] }), options);
259
+ const otherListId = new ObjectId();
260
+ const otherCategoryId = new ObjectId();
261
+ await ListsModel.db.insertOne({ ...list, _id: otherListId, categories: [{ _id: otherCategoryId, id: 'events', name: 'Events' }] });
262
+ const input = change === 'replace' ? planInput(otherListId) : planInput(listId, { list: undefined });
263
+ const changed = await plansService.changePlan(plan._id, input, options);
264
+ expect(changed.list?.toHexString()).toBe(input.list?.toHexString());
265
+ expect(changed.workAreaCategories).toEqual([]);
266
+ expect(changed.days).toEqual(plan.days);
267
+ expect(changed.workAreas).toEqual(plan.workAreas);
268
+ expect(changed.shifts).toHaveLength(1);
269
+ });
270
+ it('validates explicit replacement bindings against the new employee list before changing the plan', async () => {
271
+ const plan = await setupPlan();
272
+ const bindings = [{ area: housekeepingAreaId, categoryIds: [categoryId] }];
273
+ await plansService.changePlan(plan._id, planInput(listId, { workAreaCategories: bindings }), options);
274
+ const otherListId = new ObjectId();
275
+ const otherCategoryId = new ObjectId();
276
+ await ListsModel.db.insertOne({ ...list, _id: otherListId, categories: [{ _id: otherCategoryId, id: 'events', name: 'Events' }] });
277
+ const previous = await ShiftPlanModel.db.findOne({ _id: plan._id });
278
+ await expect(plansService.changePlan(plan._id, planInput(otherListId, { workAreaCategories: bindings }), options)).rejects.toThrow('VALIDATION_FAILED');
279
+ await expect(plansService.changePlan(plan._id, planInput(listId, { list: undefined, workAreaCategories: bindings }), options)).rejects.toThrow('VALIDATION_FAILED');
280
+ expect(await ShiftPlanModel.db.findOne({ _id: plan._id })).toEqual(previous);
281
+ const replacement = [{ area: housekeepingAreaId, categoryIds: [otherCategoryId] }];
282
+ const changed = await plansService.changePlan(plan._id, planInput(otherListId, { workAreaCategories: replacement }), options);
283
+ expect(changed.list.equals(otherListId)).toBe(true);
284
+ expect(changed.workAreaCategories).toEqual(replacement);
285
+ });
217
286
  it('stores, preserves and clears the default participant state', async () => {
218
287
  const plan = await setupPlan();
219
288
  const set = await plansService.changePlan(plan._id, planInput(listId, { defaultParticipantState: 'confirmed' }), options);
@@ -257,6 +326,38 @@ describe('shift planning', () => {
257
326
  const history = await HistoryModel.db.findOne({ 'history.parent': shift._id });
258
327
  expect(history).toMatchObject({ assignments: [{ customer: customerId }, { customer: secondCustomerId }], history: { model: 'shifts', reason: 'create', user: options.user } });
259
328
  });
329
+ it('returns the complete scoped schedule in plan details while keeping standalone shift lists paginated', async () => {
330
+ const plan = await setupPlan();
331
+ const shift = await shiftsService.createShift(plan._id, shiftInput(plan, housekeepingAreaId, {
332
+ start: `${day}T16:00:00+02:00`, end: `${day}T18:00:00+02:00`,
333
+ }), options);
334
+ const document = (await ShiftModel.db.findOne({ _id: shift._id }));
335
+ const additionalShifts = Array.from({ length: 30 }, (_, index) => ({
336
+ ...document, _id: new ObjectId(), assignments: [],
337
+ start: new Date(Date.parse(`${day}T06:00:00Z`) + index * 60000).toISOString(),
338
+ end: new Date(Date.parse(`${day}T07:00:00Z`) + index * 60000).toISOString(),
339
+ }));
340
+ await ShiftModel.db.insertMany([
341
+ ...additionalShifts,
342
+ { ...document, _id: new ObjectId(), project: 'foreign', assignments: [] },
343
+ { ...document, _id: new ObjectId(), company: 'foreign', assignments: [] },
344
+ { ...document, _id: new ObjectId(), plan: new ObjectId() },
345
+ ]);
346
+ const detail = (await plansService.singlePlan(plan._id, options));
347
+ expect(detail.shifts.map((entry) => entry._id)).toEqual([...additionalShifts.map((entry) => entry._id), shift._id]);
348
+ expect(detail.shifts[30]).toMatchObject({
349
+ _id: shift._id, participants: [{ participant: participantId, customer: customerId }],
350
+ violations: [], workingTime: [{ customer: customerId.toHexString(), daytimeMinutes: 120, nighttimeMinutes: 0, breakMinutes: 0 }],
351
+ });
352
+ const firstPage = await shiftsService.listShifts(plan._id, {}, options);
353
+ const secondPage = await shiftsService.listShifts(plan._id, { site: 2 }, options);
354
+ expect(firstPage).toMatchObject({ all: 31, count: 30 });
355
+ expect(secondPage).toMatchObject({ all: 31, count: 1 });
356
+ expect(secondPage.data[0]._id.equals(shift._id)).toBe(true);
357
+ await expect(plansService.changePlan(plan._id, planInput(listId, { list: undefined }), options)).rejects.toThrow('SHIFT_PLAN_REFERENCES_IN_USE');
358
+ expect(await plansService.singlePlan(plan._id, { ...options, company: 'foreign' })).toBeNull();
359
+ expect(await plansService.singlePlan(plan._id, { ...options, project: 'foreign' })).toBeNull();
360
+ });
260
361
  it('stores shift structures and people requirements and replaces them on change', async () => {
261
362
  const plan = await setupPlan();
262
363
  const structureId = '507f1f77bcf86cd799439011';
@@ -743,44 +844,8 @@ describe('shift planning', () => {
743
844
  expect(member.data[0].area).not.toHaveProperty('company');
744
845
  expect(member.data[0].area).not.toHaveProperty('project');
745
846
  });
746
- it('searches plans by name while escaping regex metacharacters', async () => {
747
- await setupPlan();
748
- await plansService.createPlan(planInput(listId, { name: 'Events [October]' }), options);
749
- const matched = await plansService.listPlans({ search: 'events' }, options);
750
- expect(matched.all).toBe(1);
751
- expect(matched.data[0].name).toBe('Events [October]');
752
- const escaped = await plansService.listPlans({ search: '[October]' }, options);
753
- expect(escaped.all).toBe(1);
754
- expect((await plansService.listPlans({ search: 'missing' }, options)).all).toBe(0);
755
- });
756
- it('lists weekly plans by planning dates and keeps both bounds on the same day', async () => {
757
- const first = await setupPlan();
758
- const futureCreated = await plansService.createPlan(planInput(listId), options);
759
- await createDays(futureCreated._id, options, [{ date: '2026-10-12', areas: areaLinks() }]);
760
- const disjointCreated = await plansService.createPlan(planInput(listId), options);
761
- await createDays(disjointCreated._id, options, [{ date: '2026-10-01', areas: areaLinks() }, { date: '2026-10-20', areas: areaLinks() }]);
762
- // mongo-mock has no distinct implementation; reproduce the day-date range query over the stored day documents.
763
- const storedDays = await DayModel.db.find({ company: options.company, project: options.project }).toArray();
764
- const distinctSpy = jest.spyOn(DayModel.db, 'distinct').mockImplementation((async (_field, filter) => {
765
- const range = (filter.date ?? {});
766
- const plans = storedDays
767
- .filter((entry) => (!range.$gte || entry.date >= range.$gte) && (!range.$lt || entry.date < range.$lt))
768
- .map((entry) => entry.plan.toHexString());
769
- return [...new Set(plans)].map((hex) => new ObjectId(hex));
770
- }));
771
- try {
772
- const filtered = await plansService.listPlans({ list: listId, from: '2026-10-10', to: '2026-10-15' }, options);
773
- expect(filtered.all).toBe(1);
774
- expect(filtered.data[0]._id.equals(futureCreated._id)).toBe(true);
775
- const ordered = await plansService.listPlans({ list: listId, from: day, to: '2026-10-13' }, options);
776
- expect(ordered.all).toBe(2);
777
- expect(ordered.data[0]._id.equals(first._id)).toBe(true);
778
- await expect(plansService.listPlans({ list: listId, from: day, to: day }, options)).rejects.toThrow('VALIDATION_FAILED');
779
- expect(await plansService.singlePlan(new ObjectId(), options)).toBeNull();
780
- }
781
- finally {
782
- distinctSpy.mockRestore();
783
- }
847
+ it('returns null for a missing plan', async () => {
848
+ expect(await plansService.singlePlan(new ObjectId(), options)).toBeNull();
784
849
  });
785
850
  it('permits overlapping departments in one area while keeping shift operations bound to their plan', async () => {
786
851
  const restaurant = await setupPlan();