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]>
292 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|