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]>
233 lines
8.2 KiB
TypeScript
233 lines
8.2 KiB
TypeScript
import { GraphClientService, GraphRequestError } from './graph-client.service';
|
|
|
|
function jsonResponse(status: number, body: unknown): Response {
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
json: () => Promise.resolve(body),
|
|
text: () => Promise.resolve(JSON.stringify(body)),
|
|
} as Response;
|
|
}
|
|
|
|
describe('GraphClientService', () => {
|
|
let service: GraphClientService;
|
|
let fetchMock: jest.Mock;
|
|
|
|
beforeEach(() => {
|
|
service = new GraphClientService();
|
|
fetchMock = jest.fn();
|
|
global.fetch = fetchMock;
|
|
process.env.OIDC_ISSUER_URL =
|
|
'https://login.microsoftonline.com/tenant-1/v2.0';
|
|
process.env.OIDC_CLIENT_ID = 'client-1';
|
|
process.env.OIDC_CLIENT_SECRET = 'secret-1';
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete process.env.OIDC_ISSUER_URL;
|
|
delete process.env.OIDC_CLIENT_ID;
|
|
delete process.env.OIDC_CLIENT_SECRET;
|
|
});
|
|
|
|
describe('isConfigured', () => {
|
|
it('returns true when all three SSO env vars are set', () => {
|
|
expect(service.isConfigured()).toBe(true);
|
|
});
|
|
|
|
it('returns false when a required env var is missing', () => {
|
|
delete process.env.OIDC_CLIENT_SECRET;
|
|
expect(service.isConfigured()).toBe(false);
|
|
});
|
|
|
|
it('returns false when the issuer URL has no tenant segment', () => {
|
|
process.env.OIDC_ISSUER_URL = 'https://login.microsoftonline.com/';
|
|
expect(service.isConfigured()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('createEvent', () => {
|
|
it('fetches a token from the tenant derived from OIDC_ISSUER_URL, then posts an all-day event', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(
|
|
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
|
)
|
|
.mockResolvedValueOnce(jsonResponse(201, { id: 'graph-event-1' }));
|
|
|
|
const result = await service.createEvent(
|
|
'group-1',
|
|
{ title: 'Durchsicht V-001', date: new Date(Date.UTC(2026, 2, 7)) },
|
|
'tx-durchsicht',
|
|
);
|
|
|
|
expect(result).toBe('graph-event-1');
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
const [tokenUrl, tokenInit] = fetchMock.mock.calls[0] as [
|
|
string,
|
|
{ body: URLSearchParams },
|
|
];
|
|
expect(tokenUrl).toBe(
|
|
'https://login.microsoftonline.com/tenant-1/oauth2/v2.0/token',
|
|
);
|
|
expect(tokenInit.body.get('client_id')).toBe('client-1');
|
|
expect(tokenInit.body.get('client_secret')).toBe('secret-1');
|
|
const [eventUrl, eventInit] = fetchMock.mock.calls[1] as [
|
|
string,
|
|
{ headers: Record<string, string>; body: string },
|
|
];
|
|
expect(eventUrl).toBe(
|
|
'https://graph.microsoft.com/v1.0/groups/group-1/events',
|
|
);
|
|
expect(eventInit.headers.Authorization).toBe('Bearer tok-1');
|
|
const body = JSON.parse(eventInit.body) as {
|
|
subject: string;
|
|
isAllDay: boolean;
|
|
start: { dateTime: string };
|
|
end: { dateTime: string };
|
|
transactionId: string;
|
|
};
|
|
expect(body.subject).toBe('Durchsicht V-001');
|
|
// Lets Graph drop a repeated create after a timed-out first attempt.
|
|
expect(body.transactionId).toBe('tx-durchsicht');
|
|
expect(body.isAllDay).toBe(true);
|
|
expect(body.start.dateTime).toBe('2026-03-07T00:00:00.0000000');
|
|
expect(body.end.dateTime).toBe('2026-03-08T00:00:00.0000000');
|
|
});
|
|
|
|
it('caches the token and does not fetch it again for a second call', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(
|
|
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
|
)
|
|
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-a' }))
|
|
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-b' }));
|
|
|
|
await service.createEvent(
|
|
'group-1',
|
|
{ title: 'A', date: new Date(Date.UTC(2026, 2, 7)) },
|
|
'tx-a',
|
|
);
|
|
await service.createEvent(
|
|
'group-1',
|
|
{ title: 'B', date: new Date(Date.UTC(2026, 2, 8)) },
|
|
'tx-b',
|
|
);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('throws with the response body when Graph returns an error status', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(
|
|
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
|
)
|
|
.mockResolvedValueOnce(jsonResponse(403, { error: 'Forbidden' }));
|
|
|
|
await expect(
|
|
service.createEvent(
|
|
'group-1',
|
|
{ title: 'A', date: new Date(Date.UTC(2026, 2, 7)) },
|
|
'tx-a',
|
|
),
|
|
).rejects.toThrow(/createEvent fehlgeschlagen \(403\)/);
|
|
});
|
|
});
|
|
|
|
describe('updateEvent', () => {
|
|
it('never sends a transactionId, which Graph does not let an update change', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(
|
|
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
|
)
|
|
.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
|
|
await service.updateEvent('group-1', 'event-1', {
|
|
title: 'A',
|
|
date: new Date(Date.UTC(2026, 2, 7)),
|
|
});
|
|
|
|
const [, init] = fetchMock.mock.calls[1] as [string, { body: string }];
|
|
expect(JSON.parse(init.body)).not.toHaveProperty('transactionId');
|
|
});
|
|
|
|
it('rejects with a GraphRequestError carrying the HTTP status', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(
|
|
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
|
)
|
|
.mockResolvedValueOnce(jsonResponse(404, { error: 'NotFound' }));
|
|
|
|
const update = service.updateEvent('group-1', 'event-1', {
|
|
title: 'A',
|
|
date: new Date(Date.UTC(2026, 2, 7)),
|
|
});
|
|
|
|
await expect(update).rejects.toBeInstanceOf(GraphRequestError);
|
|
await expect(update).rejects.toMatchObject({ status: 404 });
|
|
});
|
|
});
|
|
|
|
// The outbox claim expires after a fixed time; a Graph call hanging longer
|
|
// would let a second pod take over the same row while this one still works.
|
|
it('bounds every request to Microsoft with a timeout', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(
|
|
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
|
)
|
|
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-1' }))
|
|
.mockResolvedValueOnce(jsonResponse(200, {}))
|
|
.mockResolvedValueOnce(jsonResponse(204, {}));
|
|
const input = { title: 'A', date: new Date(Date.UTC(2026, 2, 7)) };
|
|
|
|
await service.createEvent('group-1', input, 'tx-1');
|
|
await service.updateEvent('group-1', 'event-1', input);
|
|
await service.deleteEvent('group-1', 'event-1');
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
for (const [, init] of fetchMock.mock.calls as [string, RequestInit][]) {
|
|
expect(init.signal).toBeInstanceOf(AbortSignal);
|
|
}
|
|
});
|
|
|
|
// Ids end up in the URL path: a value with "/", "?" or "#" must not be
|
|
// able to point the request at another Graph resource.
|
|
it('encodes group and event ids in every request path', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(
|
|
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
|
)
|
|
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-1' }))
|
|
.mockResolvedValueOnce(jsonResponse(200, {}))
|
|
.mockResolvedValueOnce(jsonResponse(204, {}));
|
|
const input = { title: 'A', date: new Date(Date.UTC(2026, 2, 7)) };
|
|
const groupId = '../users/x?$top=1#';
|
|
const eventId = 'AAMk/abc+def=';
|
|
|
|
await service.createEvent(groupId, input, 'tx-1');
|
|
await service.updateEvent(groupId, eventId, input);
|
|
await service.deleteEvent(groupId, eventId);
|
|
|
|
const urls = (fetchMock.mock.calls as [string][]).map(([url]) => url);
|
|
const group = '..%2Fusers%2Fx%3F%24top%3D1%23';
|
|
const event = 'AAMk%2Fabc%2Bdef%3D';
|
|
expect(urls.slice(1)).toEqual([
|
|
`https://graph.microsoft.com/v1.0/groups/${group}/events`,
|
|
`https://graph.microsoft.com/v1.0/groups/${group}/events/${event}`,
|
|
`https://graph.microsoft.com/v1.0/groups/${group}/events/${event}`,
|
|
]);
|
|
});
|
|
|
|
describe('deleteEvent', () => {
|
|
it('treats a 404 as success (idempotent delete)', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(
|
|
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
|
)
|
|
.mockResolvedValueOnce(jsonResponse(404, {}));
|
|
|
|
await expect(
|
|
service.deleteEvent('group-1', 'missing-event'),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
});
|