fix: Berechtigungen der Tagesübersicht, WebDAV-Ausbau, Tests instand gesetzt
Build and Push Multi-Platform Images / build-and-push (push) Successful in 55s

Die tägliche E-Mail-Zusammenfassung zeigte Nutzern Bereiche, für die sie
keine Berechtigung haben. Zwei unabhängige Ursachen:

- Das Backend prüfte weiterhin die Altgruppe PM_Belege. Die Umbenennung
  zu PM_Buchhaltung (ca1d371) war nur im Frontend angekommen, weshalb
  der Digest "Manuell bearbeiten" und "In Agrarmonitor" anbot, während
  die Oberfläche beide Bereiche sperrte.
- Der Cron-Versand wertet die Gruppen aus user_settings aus. Diese Spalte
  wurde nur beim Aufruf der Benutzereinstellungen gefüllt; entzogene
  Berechtigungen erreichten den Digest daher unter Umständen nie.

Behoben durch Angleichen des Gruppen-Mappings und den neuen
UserIdentitySyncService, der E-Mail, Benutzername und Gruppen bei jedem
authentifizierten Request aus dem Token spiegelt – ohne den Request zu
blockieren und ohne DB-Zugriff, solange sich das Token nicht ändert. Die
doppelte Identitätspflege im UserSettingsService entfällt.

WebDAV wird nicht eingesetzt und ist entfernt; Export-Ziele bieten nur
noch FTP. Damit verschwindet das ESM-Paket webdav, an dem zwei
Jest-Suites bereits beim Parsen scheiterten.

Veraltete Tests instand gesetzt: email.controller und settings.controller
mockten weniger Abhängigkeiten, als die Klassen inzwischen haben;
postprocessing.service.spec beschrieb noch das alte Regelmodell mit
Einzelfeldern statt FilterJson und ist gegen die heutige Filter-Engine
neu geschrieben (AND/OR, verschachtelte Gruppen, Fehlerprotokollierung).

Enthält außerdem den Arbeitsstand der E-Rechnungs-Mandantenzuordnung, da
sich beide Änderungen dieselben Dateien teilen (SettingsPage, package.json).

98 Tests in 12 Suites grün, Backend- und Frontend-Build sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
bjoernpoettkerandClaude Opus 5 committed 2026-09-01 00:06:50 +02:00
1 parent 32ba772e4e
commit b7b65c64ae
41 files changed
+2462 -443

No files matched your search

@@ -3,7 +3,6 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ExportTarget } from '../database/entities/export-target.entity';
import * as ftp from 'basic-ftp';
import { createClient, type WebDAVClient } from 'webdav';
@Injectable()
export class ExportService {
@@ -29,9 +28,6 @@ export class ExportService {
case 'ftp':
await this.uploadFtp(target, filename, content);
break;
case 'webdav':
await this.uploadWebDav(target, filename, content);
break;
default:
throw new Error(`Unbekanntes Protokoll: ${target.Protocol}`);
}
@@ -47,9 +43,6 @@ export class ExportService {
case 'ftp':
await this.testFtp(target);
break;
case 'webdav':
await this.testWebDav(target);
break;
default:
return {
success: false,
@@ -57,8 +50,11 @@ export class ExportService {
};
}
return { success: true, message: 'Verbindung erfolgreich.' };
} catch (err: any) {
return { success: false, message: err.message };
} catch (err: unknown) {
return {
success: false,
message: err instanceof Error ? err.message : String(err),
};
}
}
@@ -102,31 +98,4 @@ export class ExportService {
client.close();
}
}
private async uploadWebDav(
target: ExportTarget,
filename: string,
content: Buffer,
): Promise<void> {
const client = this.createWebDavClient(target);
const remotePath = `${target.RemotePath || '/'}/${filename}`;
await client.putFileContents(remotePath, content);
this.logger.log(`WebDAV Upload: ${remotePath} → ${target.Name}`);
}
private async testWebDav(target: ExportTarget): Promise<void> {
const client = this.createWebDavClient(target);
await client.getDirectoryContents(target.RemotePath || '/');
}
private createWebDavClient(target: ExportTarget): WebDAVClient {
const protocol = target.Port === 443 ? 'https' : 'http';
const port = target.Port ? `:${target.Port}` : '';
const url = `${protocol}://${target.Host}${port}`;
return createClient(url, {
username: target.Username || undefined,
password: target.Password || undefined,
});
}
}
@@ -1,56 +1,68 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { getRepositoryToken } from '@nestjs/typeorm';
import { PostprocessingService } from './postprocessing.service';
import { Postprocessing } from '../database/entities/postprocessing.entity';
import {
Postprocessing,
type FilterGroup,
} from '../database/entities/postprocessing.entity';
import { PostprocessingAction } from '../database/entities/postprocessing-action.entity';
import { PostprocessingLog } from '../database/entities/postprocessing-log.entity';
import { PaperlessService } from '../paperless/paperless.service';
import { MailService } from './mail.service';
import { ExportService } from './export.service';
const mockRules: Partial<Postprocessing>[] = [
{
Id: 1,
Name: 'Rule1',
DocumentTypeId: 5,
CorrespondentId: null,
OwnerId: null,
TagId: null,
Order: 1,
IsActive: true,
NoFurther: false,
},
{
Id: 2,
Name: 'StopRule',
DocumentTypeId: null,
CorrespondentId: null,
OwnerId: null,
TagId: null,
Order: 2,
IsActive: true,
NoFurther: true,
},
];
/** Bedingung „Dokumenttyp == <id>" als Filterbaum. */
const documentTypeIs = (id: number): FilterGroup => ({
combinator: 'AND',
rules: [{ field: 'document_type', operator: 'equals', value: id }],
});
const mockActions: Partial<PostprocessingAction>[] = [
{
Id: 1,
PostprocessingId: 1,
ActionType: 2,
Content: '99',
Order: 1,
IsActive: true,
},
];
/** Aktion „Tag 99 setzen" (ActionType 3). */
const addTagAction = (
postprocessingId: number,
): Partial<PostprocessingAction> => ({
Id: postprocessingId * 10,
PostprocessingId: postprocessingId,
ActionType: 3,
Content: { addTags: [99] },
Order: 1,
IsActive: true,
});
const rule = (over: Partial<Postprocessing>): Partial<Postprocessing> => ({
Name: `Regel ${over.Id}`,
FilterJson: documentTypeIs(5),
Order: over.Id ?? 1,
IsActive: true,
NoFurther: false,
...over,
});
describe('PostprocessingService', () => {
let service: PostprocessingService;
let ppRepo: any;
let ppActionRepo: any;
let paperlessService: any;
let ppRepo: { find: jest.Mock };
let actionRepo: { find: jest.Mock };
let logRepo: { create: jest.Mock; save: jest.Mock };
let paperlessService: {
updateDocument: jest.Mock;
getCorrespondents: jest.Mock;
getDocumentTypes: jest.Mock;
};
beforeEach(async () => {
ppRepo = { find: jest.fn().mockResolvedValue(mockRules) };
ppActionRepo = { find: jest.fn().mockResolvedValue(mockActions) };
paperlessService = { updateDocument: jest.fn().mockResolvedValue({}) };
/** Baut den Service; `errorTagId` steuert POSTPROCESSING_ERROR_TAG. */
async function createService(errorTagId = 0) {
ppRepo = { find: jest.fn().mockResolvedValue([]) };
actionRepo = { find: jest.fn().mockResolvedValue([]) };
logRepo = {
create: jest.fn().mockImplementation((d: unknown) => d),
save: jest.fn().mockResolvedValue({}),
};
paperlessService = {
updateDocument: jest.fn().mockResolvedValue({}),
getCorrespondents: jest.fn().mockResolvedValue({ results: [] }),
getDocumentTypes: jest.fn().mockResolvedValue([]),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -58,21 +70,41 @@ describe('PostprocessingService', () => {
{ provide: getRepositoryToken(Postprocessing), useValue: ppRepo },
{
provide: getRepositoryToken(PostprocessingAction),
useValue: ppActionRepo,
useValue: actionRepo,
},
{ provide: getRepositoryToken(PostprocessingLog), useValue: logRepo },
{
provide: ConfigService,
useValue: { get: jest.fn().mockReturnValue(errorTagId) },
},
{ provide: PaperlessService, useValue: paperlessService },
{ provide: MailService, useValue: { sendMail: jest.fn() } },
{ provide: ExportService, useValue: { exportFile: jest.fn() } },
],
}).compile();
service = module.get<PostprocessingService>(PostprocessingService);
return module.get<PostprocessingService>(PostprocessingService);
}
const doc = (over: Record<string, any> = {}) => ({
id: 100,
document_type: 5,
correspondent: null,
tags: [],
title: 'Rechnung Mai',
...over,
});
beforeEach(async () => {
service = await createService();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('evaluate loads active rules in order', async () => {
await service.evaluate({ documentId: 100, documentTypeId: 5, tagIds: [] });
it('lädt nur aktive Regeln in Reihenfolge', async () => {
await service.evaluate(doc());
expect(ppRepo.find).toHaveBeenCalledWith({
where: { IsActive: true },
@@ -80,41 +112,150 @@ describe('PostprocessingService', () => {
});
});
it('evaluate executes matching actions', async () => {
// Rule1 matches documentTypeId=5 → action adds tag
await service.evaluate({ documentId: 100, documentTypeId: 5, tagIds: [] });
it('führt die Aktionen einer passenden Regel aus', async () => {
ppRepo.find.mockResolvedValue([rule({ Id: 1 })]);
actionRepo.find.mockResolvedValue([addTagAction(1)]);
expect(ppActionRepo.find).toHaveBeenCalledWith({
await service.evaluate(doc({ tags: [7] }));
expect(actionRepo.find).toHaveBeenCalledWith({
where: { PostprocessingId: 1, IsActive: true },
order: { Order: 'ASC' },
});
expect(paperlessService.updateDocument).toHaveBeenCalledWith(100, {
tags: [99],
tags: [7, 99],
});
});
it('evaluate stops at NoFurther rule', async () => {
// Rule1 matches, Rule2 also matches (no filters) + NoFurther → stops
await service.evaluate({ documentId: 100, documentTypeId: 5, tagIds: [] });
it('überspringt Regeln, deren Filter nicht zutrifft', async () => {
ppRepo.find.mockResolvedValue([rule({ Id: 1 })]);
// Actions loaded for rule 1 and rule 2, but no rule after rule 2
expect(ppActionRepo.find).toHaveBeenCalledTimes(2);
await service.evaluate(doc({ document_type: 999 }));
expect(actionRepo.find).not.toHaveBeenCalled();
});
it('evaluate skips non-matching rules', async () => {
// documentTypeId=999 doesn't match Rule1 (requires 5) but matches Rule2 (no filter)
ppActionRepo.find.mockResolvedValue([]);
await service.evaluate({
documentId: 100,
documentTypeId: 999,
tagIds: [],
});
it('überspringt Regeln ohne Bedingungen', async () => {
ppRepo.find.mockResolvedValue([
rule({ Id: 1, FilterJson: { combinator: 'AND', rules: [] } }),
]);
// Rule1 skipped, Rule2 matched → only 1 action lookup
expect(ppActionRepo.find).toHaveBeenCalledTimes(1);
expect(ppActionRepo.find).toHaveBeenCalledWith({
where: { PostprocessingId: 2, IsActive: true },
await service.evaluate(doc());
expect(actionRepo.find).not.toHaveBeenCalled();
});
it('bricht nach einer Regel mit NoFurther ab', async () => {
ppRepo.find.mockResolvedValue([
rule({ Id: 1, NoFurther: true }),
rule({ Id: 2 }),
]);
await service.evaluate(doc());
expect(actionRepo.find).toHaveBeenCalledTimes(1);
expect(actionRepo.find).toHaveBeenCalledWith({
where: { PostprocessingId: 1, IsActive: true },
order: { Order: 'ASC' },
});
});
it('verlangt bei AND alle Bedingungen', async () => {
ppRepo.find.mockResolvedValue([
rule({
Id: 1,
FilterJson: {
combinator: 'AND',
rules: [
{ field: 'document_type', operator: 'equals', value: 5 },
{ field: 'title', operator: 'contains', value: 'Gutschrift' },
],
},
}),
]);
await service.evaluate(doc());
expect(actionRepo.find).not.toHaveBeenCalled();
});
it('genügt bei OR einer Bedingung', async () => {
ppRepo.find.mockResolvedValue([
rule({
Id: 1,
FilterJson: {
combinator: 'OR',
rules: [
{ field: 'document_type', operator: 'equals', value: 999 },
{ field: 'title', operator: 'contains', value: 'rechnung' },
],
},
}),
]);
await service.evaluate(doc());
expect(actionRepo.find).toHaveBeenCalledTimes(1);
});
it('wertet verschachtelte Gruppen aus', async () => {
ppRepo.find.mockResolvedValue([
rule({
Id: 1,
FilterJson: {
combinator: 'AND',
rules: [
{ field: 'document_type', operator: 'equals', value: 5 },
{
combinator: 'OR',
rules: [
{ field: 'tag', operator: 'contains', value: 42 },
{ field: 'tag', operator: 'contains', value: 7 },
],
},
],
},
}),
]);
await service.evaluate(doc({ tags: [7] }));
expect(actionRepo.find).toHaveBeenCalledTimes(1);
});
it('protokolliert erfolgreiche Aktionen', async () => {
ppRepo.find.mockResolvedValue([rule({ Id: 1 })]);
actionRepo.find.mockResolvedValue([addTagAction(1)]);
await service.evaluate(doc());
expect(logRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
PostprocessingId: 1,
DocumentId: 100,
Status: 'success',
}),
);
});
it('setzt bei fehlgeschlagener Aktion den Fehler-Tag und protokolliert ihn', async () => {
service = await createService(500);
ppRepo.find.mockResolvedValue([rule({ Id: 1 })]);
actionRepo.find.mockResolvedValue([addTagAction(1)]);
paperlessService.updateDocument.mockRejectedValueOnce(
new Error('Paperless nicht erreichbar'),
);
await service.evaluate(doc({ tags: [7] }));
expect(logRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
Status: 'error',
Message: 'Paperless nicht erreichbar',
}),
);
expect(paperlessService.updateDocument).toHaveBeenLastCalledWith(100, {
tags: [7, 500],
});
});
});