fix: T4 browser-check findings, interval on season state, T6 plan refresh
- Honey batch create omits an empty batch number (was 400); product form
labels the legacy total weight, detail shows jar weight and shelf life;
bottling hints next to their fields, values cleared on product change,
post-save water-content wording; withdraw reason placeholder not
selectable; honey batch actions offline-disabled with reason, 44 px.
- Offline signal: one slow request no longer flips the app offline; two
consecutive timeouts do, any HTTP answer clears it, aborts never count.
- Phenology sheet as a side card on desktop with focus return; confirm
dialogs as alertdialog; step-up texts in du-form ("Das Passwort stimmt
nicht."); backup import gives its disabled reason; missing event labels
plus a guard test; profile buttons 44 px.
- GET /phenology/state returns intervalDays via the shared
inspectionIntervalOf (autopilot, digest, season state use one source).
- T6 plan aligned with the code after T4, plus owner decisions of
2026-10-15 (Berlin day for restrictions, COMPLIANCE category, override
without step-up).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
9760ce154f
commit
7a1480138d
63 files changed
+1076
-251
No files matched your search
@@ -0,0 +1,61 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { ReauthService } from './reauth.service';
|
||||
|
||||
describe('ReauthService', () => {
|
||||
const baseUser = {
|
||||
id: 'u1',
|
||||
email: '[email protected]',
|
||||
passwordHash: bcrypt.hashSync('richtig', 4),
|
||||
mfaEnabled: false,
|
||||
mfaSecret: null,
|
||||
mfaLastTimeStep: null,
|
||||
authLockedUntil: null,
|
||||
isActive: true,
|
||||
};
|
||||
const accountGuard = {
|
||||
isLocked: jest.fn(),
|
||||
recordFailure: jest.fn(),
|
||||
recordSuccess: jest.fn(),
|
||||
consumeTotp: jest.fn(),
|
||||
};
|
||||
const prisma = { user: { findUnique: jest.fn() } };
|
||||
const service = new ReauthService(prisma as never, accountGuard as never);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
prisma.user.findUnique.mockResolvedValue(baseUser);
|
||||
accountGuard.isLocked.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('sagt bei falschem Passwort, dass es nicht stimmt', async () => {
|
||||
await expect(service.verify('u1', { password: 'falsch' })).rejects.toThrow(
|
||||
new UnauthorizedException('Das Passwort stimmt nicht.'),
|
||||
);
|
||||
expect(accountGuard.recordFailure).toHaveBeenCalledWith('u1');
|
||||
});
|
||||
|
||||
it('sagt bei falschem Code, dass er nicht stimmt', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue({ ...baseUser, mfaEnabled: true });
|
||||
accountGuard.consumeTotp.mockResolvedValue(false);
|
||||
await expect(
|
||||
service.verify('u1', { password: 'richtig', mfaCode: '000000' }),
|
||||
).rejects.toThrow(new UnauthorizedException('Der Code stimmt nicht.'));
|
||||
});
|
||||
|
||||
it('nennt bei gesperrtem Konto die Sperre', async () => {
|
||||
accountGuard.isLocked.mockReturnValue(true);
|
||||
await expect(service.verify('u1', { password: 'richtig' })).rejects.toThrow(
|
||||
new UnauthorizedException(
|
||||
'Zu viele Fehlversuche. Bestätigen ist für 15 Minuten gesperrt.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('lässt richtige Angaben durch', async () => {
|
||||
await expect(
|
||||
service.verify('u1', { password: 'richtig' }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(accountGuard.recordSuccess).toHaveBeenCalledWith('u1');
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,7 @@ export class ReauthService {
|
||||
});
|
||||
if (!user || !user.isActive || this.accountGuard.isLocked(user)) {
|
||||
throw new UnauthorizedException(
|
||||
'Bestätigung derzeit nicht möglich. Nach mehreren Fehlversuchen ist sie für 15 Minuten gesperrt.',
|
||||
'Zu viele Fehlversuche. Bestätigen ist für 15 Minuten gesperrt.',
|
||||
);
|
||||
}
|
||||
const passwordMatches =
|
||||
@@ -45,9 +45,7 @@ export class ReauthService {
|
||||
(await bcrypt.compare(proof.password, user.passwordHash));
|
||||
if (!passwordMatches) {
|
||||
await this.accountGuard.recordFailure(user.id);
|
||||
throw new UnauthorizedException(
|
||||
'Bitte zur Bestätigung Ihr aktuelles Passwort eingeben',
|
||||
);
|
||||
throw new UnauthorizedException('Das Passwort stimmt nicht.');
|
||||
}
|
||||
if (user.mfaEnabled) {
|
||||
const codeMatches =
|
||||
@@ -55,9 +53,7 @@ export class ReauthService {
|
||||
(await this.accountGuard.consumeTotp(user, proof.mfaCode));
|
||||
if (!codeMatches) {
|
||||
await this.accountGuard.recordFailure(user.id);
|
||||
throw new UnauthorizedException(
|
||||
'Bitte zur Bestätigung den aktuellen Code aus Ihrer Authenticator-App eingeben',
|
||||
);
|
||||
throw new UnauthorizedException('Der Code stimmt nicht.');
|
||||
}
|
||||
}
|
||||
await this.accountGuard.recordSuccess(user.id);
|
||||
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
} from '../common/dates';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SeasonService } from '../season/season.service';
|
||||
import { inspectionDueOn } from '../season/inspection-due';
|
||||
import {
|
||||
inspectionDueOn,
|
||||
inspectionIntervalOf,
|
||||
} from '../season/inspection-due';
|
||||
import type { SeasonSegmentKey, SeasonState } from '../season/season-types';
|
||||
import { buildEvidenceLookup } from '../suggestions/evidence-lookup';
|
||||
import { renderSuggestion } from '../suggestions/suggestion-texts';
|
||||
@@ -311,10 +314,7 @@ export class NextStepsService {
|
||||
apiaryId: season.apiaryId,
|
||||
apiaryName: nameOf(season.apiaryId),
|
||||
segment: season.segment,
|
||||
intervalDays:
|
||||
season.segment === 'WINTERRUHE'
|
||||
? null
|
||||
: intervalDays[season.segment],
|
||||
intervalDays: inspectionIntervalOf(season.segment, intervalDays),
|
||||
colonies: due,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -66,6 +66,7 @@ describe('PhenologyService', () => {
|
||||
$transaction: jest.Mock;
|
||||
apiary: { findUnique: jest.Mock; findMany: jest.Mock };
|
||||
phenologyReport: { findMany: jest.Mock };
|
||||
automationRule: { findUnique: jest.Mock };
|
||||
};
|
||||
let audit: { record: jest.Mock };
|
||||
let events: { record: jest.Mock };
|
||||
@@ -100,6 +101,8 @@ describe('PhenologyService', () => {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
phenologyReport: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
// No row: the built-in interval setting applies (SCHWARMZEIT 7 days).
|
||||
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||
};
|
||||
audit = { record: jest.fn() };
|
||||
events = { record: jest.fn() };
|
||||
@@ -436,11 +439,44 @@ describe('PhenologyService', () => {
|
||||
gts: 210.5,
|
||||
gtsThroughDay: '2026-03-31',
|
||||
note: null,
|
||||
intervalDays: 14,
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(states)).not.toMatch(/latitude|longitude/);
|
||||
});
|
||||
|
||||
it('intervalDays is the interval of the apiary segment from the rule', async () => {
|
||||
prisma.apiary.findMany.mockResolvedValue([
|
||||
{ id: 'a1', name: 'Hang' },
|
||||
{ id: 'a2', name: 'Wald' },
|
||||
]);
|
||||
season.statesFor.mockResolvedValue(
|
||||
new Map([
|
||||
['a1', seasonState({ segment: 'SCHWARMZEIT' })],
|
||||
['a2', seasonState({ apiaryId: 'a2', segment: 'WINTERRUHE' })],
|
||||
]),
|
||||
);
|
||||
|
||||
const states = await service.state();
|
||||
|
||||
expect(states.map((state) => state.intervalDays)).toEqual([7, null]);
|
||||
});
|
||||
|
||||
it('intervalDays is null while the interval rule is inactive', async () => {
|
||||
prisma.apiary.findMany.mockResolvedValue([{ id: 'a1', name: 'Hang' }]);
|
||||
season.statesFor.mockResolvedValue(new Map([['a1', seasonState()]]));
|
||||
prisma.automationRule.findUnique.mockResolvedValue({
|
||||
id: 'rule-1',
|
||||
active: false,
|
||||
config: { intervalDays: { FRUEHJAHR: 10 } },
|
||||
actionTemplate: null,
|
||||
});
|
||||
|
||||
const [state] = await service.state();
|
||||
|
||||
expect(state.intervalDays).toBeNull();
|
||||
});
|
||||
|
||||
it('reportedOn is null unless the phase comes from a report', async () => {
|
||||
prisma.apiary.findMany.mockResolvedValue([{ id: 'a1', name: 'Hang' }]);
|
||||
season.statesFor.mockResolvedValue(
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
Prisma,
|
||||
SeasonSegment,
|
||||
} from '../../generated/prisma';
|
||||
import { readRule } from '../automation/rule-config';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { berlinToday, dayToDate } from '../common/dates';
|
||||
import { utcDay } from '../domain-events/domain-event-types';
|
||||
@@ -18,6 +19,7 @@ import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate, lockRowsForShare } from '../prisma/row-lock';
|
||||
import type { SeasonNote } from '../season/season-types';
|
||||
import { inspectionIntervalOf } from '../season/inspection-due';
|
||||
import { SeasonService } from '../season/season.service';
|
||||
import type { CreatePhenologyReportDto } from './dto/create-phenology-report.dto';
|
||||
import type { UpdatePhenologyReportDto } from './dto/update-phenology-report.dto';
|
||||
@@ -52,6 +54,8 @@ export interface PhenologyApiaryState {
|
||||
gts: number | null;
|
||||
gtsThroughDay: string | null;
|
||||
note: SeasonNote;
|
||||
/** The inspection interval in force now (days); null in winter or without the rule. */
|
||||
intervalDays: number | null;
|
||||
}
|
||||
|
||||
/** A report day must exist in the calendar and must not lie after today. */
|
||||
@@ -267,6 +271,7 @@ export class PhenologyService {
|
||||
where: { revokedAt: null, date: yearRange(Number(today.slice(0, 4))) },
|
||||
select: { apiaryId: true, phase: true, date: true },
|
||||
});
|
||||
const interval = await readRule(this.prisma, 'INSPECTION_INTERVAL');
|
||||
return apiaries.flatMap((apiary) => {
|
||||
const state = states.get(apiary.id);
|
||||
if (state === undefined) {
|
||||
@@ -294,6 +299,11 @@ export class PhenologyService {
|
||||
gts: state.gts,
|
||||
gtsThroughDay: state.gtsThroughDay,
|
||||
note: state.note,
|
||||
intervalDays: inspectionIntervalOf(
|
||||
state.segment,
|
||||
interval.config.intervalDays,
|
||||
interval.active,
|
||||
),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { loggableError } from '../common/loggable-error';
|
||||
import { utcDay } from '../domain-events/domain-event-types';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { inspectionIntervalOf } from '../season/inspection-due';
|
||||
import { SEASON_SEGMENTS, SeasonSegmentKey } from '../season/season-types';
|
||||
import { JobPartialFailure } from '../system/job-run.service';
|
||||
|
||||
@@ -378,10 +379,11 @@ export class DigestService {
|
||||
.map((net) => ({
|
||||
apiaryName: names.get(net.apiaryId) ?? '',
|
||||
segment: net.segment,
|
||||
intervalDays:
|
||||
net.segment === 'WINTERRUHE' || !interval.active
|
||||
? null
|
||||
: interval.config.intervalDays[net.segment],
|
||||
intervalDays: inspectionIntervalOf(
|
||||
net.segment,
|
||||
interval.config.intervalDays,
|
||||
interval.active,
|
||||
),
|
||||
isReturn: net.isReturn,
|
||||
}))
|
||||
.sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, 'de'));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DEFAULT_INSPECTION_INTERVAL_CONFIG } from '../automation/rule-config/inspection-interval.config';
|
||||
import { inspectionDueOn } from './inspection-due';
|
||||
import { inspectionDueOn, inspectionIntervalOf } from './inspection-due';
|
||||
|
||||
const CONFIG = DEFAULT_INSPECTION_INTERVAL_CONFIG;
|
||||
const SCHWARMZEIT = {
|
||||
@@ -71,3 +71,19 @@ describe('inspectionDueOn', () => {
|
||||
),
|
||||
).toEqual({ dueOn: '2026-04-03', firstOfYear: false, intervalDays: 14 }));
|
||||
});
|
||||
|
||||
describe('inspectionIntervalOf', () => {
|
||||
const days = CONFIG.intervalDays;
|
||||
|
||||
it('is the configured interval of the segment', () => {
|
||||
expect(inspectionIntervalOf('SCHWARMZEIT', days)).toBe(7);
|
||||
expect(inspectionIntervalOf('SOMMER', days)).toBe(14);
|
||||
expect(inspectionIntervalOf('HERBST', days)).toBe(21);
|
||||
});
|
||||
|
||||
it('is null in WINTERRUHE', () =>
|
||||
expect(inspectionIntervalOf('WINTERRUHE', days)).toBeNull());
|
||||
|
||||
it('is null while the rule is inactive', () =>
|
||||
expect(inspectionIntervalOf('SCHWARMZEIT', days, false)).toBeNull());
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { addDays } from '../common/dates';
|
||||
import type { InspectionIntervalConfig } from '../automation/rule-config/rule-config.types';
|
||||
import type { SeasonState } from './season-types';
|
||||
import type { SeasonSegmentKey, SeasonState } from './season-types';
|
||||
|
||||
export interface InspectionDue {
|
||||
dueOn: string;
|
||||
@@ -9,6 +9,19 @@ export interface InspectionDue {
|
||||
intervalDays: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The inspection interval in force for a segment: null in WINTERRUHE and
|
||||
* while the rule is inactive. The one source of the interval shown to users
|
||||
* (autopilot, digest, season state).
|
||||
*/
|
||||
export function inspectionIntervalOf(
|
||||
segment: SeasonSegmentKey,
|
||||
intervalDays: InspectionIntervalConfig['intervalDays'],
|
||||
isActive = true,
|
||||
): number | null {
|
||||
return segment === 'WINTERRUHE' || !isActive ? null : intervalDays[segment];
|
||||
}
|
||||
|
||||
/**
|
||||
* The day an inspection is due: the last inspection (else the creation of
|
||||
* the colony) plus the interval of today's segment. A colony not inspected
|
||||
@@ -19,10 +32,13 @@ export function inspectionDueOn(
|
||||
season: Pick<SeasonState, 'segment' | 'springSince'>,
|
||||
config: InspectionIntervalConfig,
|
||||
): InspectionDue | null {
|
||||
if (season.segment === 'WINTERRUHE') {
|
||||
const intervalDays = inspectionIntervalOf(
|
||||
season.segment,
|
||||
config.intervalDays,
|
||||
);
|
||||
if (intervalDays === null) {
|
||||
return null;
|
||||
}
|
||||
const intervalDays = config.intervalDays[season.segment];
|
||||
const base = input.lastInspectedOn ?? input.createdOn;
|
||||
if (season.springSince !== null && base < season.springSince) {
|
||||
return { dueOn: season.springSince, firstOfYear: true, intervalDays };
|
||||
|
||||
@@ -2,6 +2,7 @@ import { INestApplication } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { readRule } from '../src/automation/rule-config';
|
||||
import { PhenologyStateService } from '../src/phenology/phenology-state.service';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { login } from './utils/api';
|
||||
@@ -23,6 +24,7 @@ interface StateBody {
|
||||
segment: string;
|
||||
phaseSource: string | null;
|
||||
reportedOn: string | null;
|
||||
intervalDays: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,11 +258,14 @@ describe('Phenology reports (e2e)', () => {
|
||||
.set(auth())
|
||||
.expect(200)
|
||||
).body as StateBody[];
|
||||
// The interval of the rule in force, the one the autopilot uses.
|
||||
const rule = await readRule(prisma, 'INSPECTION_INTERVAL');
|
||||
expect(states.find((state) => state.apiaryId === first)).toMatchObject({
|
||||
phase: 'SALWEIDE',
|
||||
segment: 'FRUEHJAHR',
|
||||
phaseSource: 'REPORT',
|
||||
reportedOn: TODAY,
|
||||
intervalDays: rule.active ? rule.config.intervalDays.FRUEHJAHR : null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user