Files
ApiCore/ApiCore-Backend/test/calendar-sync.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 10cbe9bf2d
Backend Image / build-and-push (push) Successful in 3m15s
Frontend Image / build-and-push (push) Successful in 6m2s
fix: Paket 0 – data integrity, security hardening and field usability
Backend
- Backup covers every Prisma model (bundle v3); a guard test fails for any
  new model that is neither backed up nor explicitly excluded. Unsealed
  bundles may only add rows to 27 legacy master/field tables and never
  overwrite; counters, ledgers, settings, roles etc. need a sealed file.
  Shared import limit BACKUP_MAX_IMPORT_BYTES (default 50 MB, max 256 MB)
  with warnings in log and UI; replaced document files are removed after
  commit.
- Post-commit side effects (automation, calendar sync, webhooks) run via
  runAfterCommit and never turn a committed write into a 500, so retries
  with the same Idempotency-Key no longer create duplicates.
- Calendar outbox is multi-replica safe: per-entry claims with stale
  takeover, revision-based settle, new Graph event only on 404/410,
  transactionId on create, 30 s Graph timeout, URL-encoded ids, GUID
  validation for groupId, "run now" in background and resets FAILED.
- Automation tasks are deduplicated under a row lock on the rule.
- Update DTOs follow one null rule: omitted = unchanged, null clears a
  nullable field, null on a required field is a 400.
- Varroa mites/day is recomputed on correction; withdrawal status is
  checked against the harvest date (?at=), bounded colonyIds; inspection
  create is audited; updates re-enqueue calendar entries.
- Dashboard, statistics and apiary/colony guards treat WEAK, CONSPICUOUS
  and QUEENLESS colonies as living.
- Prisma errors and body-parser errors are logged without query values or
  request bodies.
- Migration 20261012100000_calendar_sync_claims; schema index name aligned
  with the existing database object.

Frontend
- Sticky save bar sticks again (no scroll container ancestors, card uses
  overflow-clip), safe-area insets, scroll-padding, FAB hidden on forms,
  sticky desktop sidebar.
- Inspections record yes / no / not checked instead of silent defaults.
- All living colonies are selectable; shared RecordSelect, EnumSelect,
  ChoiceGroup and TriStateField replace per-page copies; selects show
  names instead of ids; labels point at real controls.
- Emptied optional fields are sent as null (emptyToNull) so every field
  can be cleared; dates default to the local day and are sent via
  toApiDate.
- Harvest withdrawal warning uses the harvest date; backup page warns when
  a backup outgrows the import limit and lists skipped tables.

Build and docs
- Docker builds fail unless lint, typecheck and unit tests pass.
- ARCHITECTURE.md updated (assumptions 9-12, 19-21, section 8) and spec
  docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md
  for the backbone (api/worker/job roles, domain event outbox) and the
  feature roadmap T3-T7.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 16:38:55 +02:00

292 lines
10 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { CalendarSyncService } from '../src/calendar-sync/calendar-sync.service';
import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service';
import { GraphClientService } from '../src/calendar-sync/graph-client.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
/**
* The M365 outbox against the real database, with Microsoft Graph replaced
* by a recorder: several workers (pods, the cron and "Jetzt
* synchronisieren") must never write the same entry twice, and an edit
* that arrives while an entry is being synced must not get lost.
*/
describe('Calendar sync outbox (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
let calendarSync: CalendarSyncService;
let graph: GraphClientService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const taskIds: string[] = [];
let createdEvents: string[];
let updateEvent: jest.SpyInstance;
let eventCounter = 0;
const adminEmail = `calendar-sync-e2e-${suffix}@example.com`;
const password = 'SuperSecret123!';
let token: string;
const settings = {
id: 'singleton',
enabled: true,
groupId: 'group-e2e',
syncTask: true,
syncInspection: true,
syncTreatment: true,
syncFeeding: true,
syncHarvest: true,
syncQueenAssignment: true,
updatedAt: new Date(),
};
const wait = (ms: number) => new Promise((done) => setTimeout(done, ms));
/** Polls until the condition holds - for work that runs in the background. */
async function eventually(condition: () => Promise<boolean> | boolean) {
for (let i = 0; i < 100; i++) {
if (await condition()) return;
await wait(50);
}
throw new Error('condition not met in time');
}
async function queueTask(title: string) {
const task = await prisma.task.create({
data: { title: `${title} ${suffix}`, dueDate: new Date('2027-05-01') },
});
taskIds.push(task.id);
await prisma.calendarSyncOutboxEntry.create({
data: { sourceType: 'TASK', sourceId: task.id },
});
return task;
}
const entryFor = (taskId: string) =>
prisma.calendarSyncOutboxEntry.findUniqueOrThrow({
where: { sourceType_sourceId: { sourceType: 'TASK', sourceId: taskId } },
});
/** Graph calls made for the tasks of this spec (not other leftovers). */
const createsFor = (title: string) =>
createdEvents.filter((created) => created === `${title} ${suffix}`);
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
calendarSync = app.get(CalendarSyncService);
graph = app.get(GraphClientService);
await prisma.user.create({
data: {
email: adminEmail,
passwordHash: await bcrypt.hash(password, 12),
name: 'Calendar Sync Admin',
role: { connect: { key: 'ADMIN' } },
},
});
token = (await login(app, adminEmail, password)).accessToken;
});
beforeEach(() => {
createdEvents = [];
jest
.spyOn(app.get(CalendarSyncSettingsService), 'get')
.mockResolvedValue(settings);
jest.spyOn(graph, 'isConfigured').mockReturnValue(true);
// A real Graph round trip takes a moment - long enough for a second
// worker to read the same row in the meantime.
jest.spyOn(graph, 'createEvent').mockImplementation(async (_g, input) => {
createdEvents.push(input.title);
await wait(50);
eventCounter += 1;
return `event-${suffix}-${eventCounter}`;
});
updateEvent = jest.spyOn(graph, 'updateEvent').mockResolvedValue(undefined);
jest.spyOn(graph, 'deleteEvent').mockResolvedValue(undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
afterAll(async () => {
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceType: 'TASK', sourceId: { in: taskIds } },
});
await prisma.task.deleteMany({ where: { id: { in: taskIds } } });
const admin = await prisma.user.findUnique({
where: { email: adminEmail },
});
if (admin) {
await prisma.refreshToken.deleteMany({ where: { userId: admin.id } });
await prisma.auditLog.deleteMany({ where: { userId: admin.id } });
await prisma.user.delete({ where: { id: admin.id } });
}
await app.close();
});
it('creates each event once when several workers run at the same time', async () => {
const titles = ['Parallel A', 'Parallel B', 'Parallel C'];
const tasks: { id: string }[] = [];
for (const title of titles) {
tasks.push(await queueTask(title));
}
// Two pods' cron ticks plus a click on "Jetzt synchronisieren".
await Promise.all([
calendarSync.processOutbox(),
calendarSync.processOutbox(),
calendarSync.runNow(),
]);
for (const title of titles) {
expect(createsFor(title)).toHaveLength(1);
}
for (const task of tasks) {
const entry = await entryFor(task.id);
expect(entry.status).toBe('SYNCED');
expect(entry.graphEventId).toMatch(/^event-/);
expect(entry.claimedAt).toBeNull();
}
});
it('syncs an edit that arrives while the entry is being written to M365', async () => {
const task = await queueTask('Edited during sync');
jest
.spyOn(graph, 'createEvent')
.mockImplementationOnce(async (_g, input) => {
createdEvents.push(input.title);
// The task's due date is changed right now, on another request.
await calendarSync.enqueue('TASK', task.id);
return `event-${suffix}-edited`;
});
await calendarSync.processOutbox();
const afterFirstPass = await entryFor(task.id);
// Not SYNCED: the newer state still has to go out - onto the same event.
expect(afterFirstPass.status).toBe('PENDING');
expect(afterFirstPass.graphEventId).toBe(`event-${suffix}-edited`);
expect(afterFirstPass.claimedAt).toBeNull();
await calendarSync.processOutbox();
expect(createsFor('Edited during sync')).toHaveLength(1);
expect(updateEvent).toHaveBeenCalledWith(
'group-e2e',
`event-${suffix}-edited`,
expect.objectContaining({ title: `Edited during sync ${suffix}` }),
);
expect((await entryFor(task.id)).status).toBe('SYNCED');
});
it('takes over an entry whose worker died, but not one still being worked on', async () => {
const abandoned = await queueTask('Abandoned claim');
const busy = await queueTask('Busy claim');
await prisma.calendarSyncOutboxEntry.update({
where: {
sourceType_sourceId: { sourceType: 'TASK', sourceId: abandoned.id },
},
data: { claimedAt: new Date(Date.now() - 60 * 60 * 1000) },
});
await prisma.calendarSyncOutboxEntry.update({
where: { sourceType_sourceId: { sourceType: 'TASK', sourceId: busy.id } },
data: { claimedAt: new Date() },
});
await calendarSync.processOutbox();
expect(createsFor('Abandoned claim')).toHaveLength(1);
expect((await entryFor(abandoned.id)).status).toBe('SYNCED');
expect(createsFor('Busy claim')).toHaveLength(0);
expect((await entryFor(busy.id)).status).toBe('PENDING');
// The busy worker finishes - its live claim would otherwise keep
// getStatus().running true for the other tests.
await prisma.calendarSyncOutboxEntry.update({
where: { sourceType_sourceId: { sourceType: 'TASK', sourceId: busy.id } },
data: { claimedAt: null },
});
});
it('retries a FAILED entry on "Jetzt synchronisieren", not on the cron tick', async () => {
const task = await queueTask('Failed before');
await prisma.calendarSyncOutboxEntry.update({
where: { sourceType_sourceId: { sourceType: 'TASK', sourceId: task.id } },
data: { status: 'FAILED', attempts: 5, lastError: 'Graph down' },
});
await calendarSync.processOutbox();
expect(createsFor('Failed before')).toHaveLength(0);
expect((await entryFor(task.id)).status).toBe('FAILED');
await calendarSync.runNow();
expect(createsFor('Failed before')).toHaveLength(1);
const entry = await entryFor(task.id);
expect(entry.status).toBe('SYNCED');
expect(entry.attempts).toBe(0);
expect(entry.lastError).toBeNull();
});
describe('API', () => {
const auth = () => ({ Authorization: `Bearer ${token}` });
it('answers "Jetzt synchronisieren" with 202 while the pass still runs', async () => {
const task = await queueTask('Started via API');
const title = `Started via API ${suffix}`;
let releaseCreate: () => void = () => undefined;
jest.spyOn(graph, 'createEvent').mockImplementation(async (_g, input) => {
createdEvents.push(input.title);
if (input.title === title) {
await new Promise<void>((resolve) => {
releaseCreate = resolve;
});
}
eventCounter += 1;
return `event-${suffix}-${eventCounter}`;
});
const response = await request(app.getHttpServer())
.post('/api/v1/calendar/sync/run-now')
.set(auth())
.expect(202);
expect((response.body as { running: boolean }).running).toBe(true);
await eventually(() => createsFor('Started via API').length === 1);
expect((await entryFor(task.id)).status).toBe('PENDING');
const whileRunning = await request(app.getHttpServer())
.get('/api/v1/calendar/sync/status')
.set(auth())
.expect(200);
expect((whileRunning.body as { running: boolean }).running).toBe(true);
releaseCreate();
await eventually(async () => !(await calendarSync.getStatus()).running);
expect((await entryFor(task.id)).status).toBe('SYNCED');
}, 15_000);
it('rejects a group id that is not an Entra object id with 400', async () => {
const response = await request(app.getHttpServer())
.patch('/api/v1/calendar/sync/settings')
.set(auth())
.send({ groupId: '../users/[email protected]' })
.expect(400);
expect(JSON.stringify(response.body)).toContain('Gruppen-ID');
});
// PATCH null rule: the switches cannot hold null - 400, not a 500.
it('rejects null on a switch with 400', async () => {
await request(app.getHttpServer())
.patch('/api/v1/calendar/sync/settings')
.set(auth())
.send({ enabled: null })
.expect(400);
});
});
});