fix: Berechtigungen der Tagesübersicht, WebDAV-Ausbau, Tests instand gesetzt
Build and Push Multi-Platform Images / build-and-push (push) Successful in 55s
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:
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],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user