test
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { UserEntity } from '../auth/user.entity';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
@@ -20,7 +21,14 @@ describe('TaskDigestService', () => {
|
||||
sendTaskDigestEmail: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
taskPushService = {
|
||||
sendTaskDigest: jest.fn().mockResolvedValue(undefined),
|
||||
sendTaskDigest: jest
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
enabled: true,
|
||||
subscriptionCount: 1,
|
||||
sentCount: 1,
|
||||
failedCount: 0,
|
||||
}),
|
||||
};
|
||||
service = new TaskDigestService(
|
||||
usersRepository as never,
|
||||
@@ -154,6 +162,65 @@ describe('TaskDigestService', () => {
|
||||
expect(taskPushService.sendTaskDigest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends a test digest without marking the regular digest processed', async () => {
|
||||
await saveUser({ taskDigestPreference: 'none' });
|
||||
|
||||
const result = await service.sendTestDigest('owner-1');
|
||||
|
||||
expect(mailService.sendTaskDigestEmail).toHaveBeenCalledWith(
|
||||
'owner@example.com',
|
||||
expect.objectContaining({
|
||||
displayName: 'Owner',
|
||||
todayTasks: [
|
||||
{
|
||||
title: 'Test-Benachrichtigung',
|
||||
notes:
|
||||
'Diese Aufgabe wurde nur fuer den Benachrichtigungstest erzeugt.',
|
||||
dueDate: expect.any(String),
|
||||
},
|
||||
],
|
||||
overdueTasks: [],
|
||||
}),
|
||||
);
|
||||
expect(taskPushService.sendTaskDigest).toHaveBeenCalledWith(
|
||||
'owner-1',
|
||||
expect.objectContaining({
|
||||
todayTasks: [
|
||||
expect.objectContaining({ title: 'Test-Benachrichtigung' }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.mail.sent).toBe(true);
|
||||
expect(result.push).toEqual({
|
||||
enabled: true,
|
||||
subscriptionCount: 1,
|
||||
sentCount: 1,
|
||||
failedCount: 0,
|
||||
sent: true,
|
||||
});
|
||||
expect(result.usedFallbackTask).toBe(true);
|
||||
expect(
|
||||
(await usersRepository.findOne({ where: { id: 'owner-1' } }))
|
||||
?.taskDigestMorningProcessedDate,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns separate mail and push status for test digest failures', async () => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
await saveUser();
|
||||
await saveTask({ id: 'today-task' });
|
||||
jest
|
||||
.mocked(mailService.sendTaskDigestEmail)
|
||||
.mockRejectedValueOnce(new Error('smtp down'));
|
||||
|
||||
const result = await service.sendTestDigest('owner-1');
|
||||
|
||||
expect(taskPushService.sendTaskDigest).toHaveBeenCalledTimes(1);
|
||||
expect(result.mail).toEqual({ sent: false, error: 'smtp down' });
|
||||
expect(result.push.sent).toBe(true);
|
||||
expect(result.usedFallbackTask).toBe(false);
|
||||
});
|
||||
|
||||
async function saveUser(overrides: Partial<UserEntity> = {}) {
|
||||
return usersRepository.save({
|
||||
id: overrides.id ?? 'owner-1',
|
||||
|
||||
@@ -1,13 +1,48 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { IsNull, LessThanOrEqual, Repository } from 'typeorm';
|
||||
import { UserEntity } from '../auth/user.entity';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
import type { TaskDigestEmailItem } from '../mail/mail.types';
|
||||
import { TaskPushService } from './task-push.service';
|
||||
import { UserTaskEntity } from './user-task.entity';
|
||||
import type { TaskDigestSlot } from './task-digest.types';
|
||||
|
||||
interface TaskDigestPayload {
|
||||
slot: TaskDigestSlot;
|
||||
date: string;
|
||||
tasksUrl: string;
|
||||
todayTasks: TaskDigestEmailItem[];
|
||||
overdueTasks: TaskDigestEmailItem[];
|
||||
}
|
||||
|
||||
interface TaskDigestPayloadResult {
|
||||
payload: TaskDigestPayload;
|
||||
taskCount: number;
|
||||
usedFallbackTask: boolean;
|
||||
}
|
||||
|
||||
export interface TaskNotificationTestResult {
|
||||
message: string;
|
||||
date: string;
|
||||
slot: TaskDigestSlot;
|
||||
taskCount: number;
|
||||
usedFallbackTask: boolean;
|
||||
mail: {
|
||||
sent: boolean;
|
||||
error?: string;
|
||||
};
|
||||
push: {
|
||||
enabled: boolean;
|
||||
subscriptionCount: number;
|
||||
sentCount: number;
|
||||
failedCount: number;
|
||||
sent: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TaskDigestService {
|
||||
private readonly logger = new Logger(TaskDigestService.name);
|
||||
@@ -55,46 +90,88 @@ export class TaskDigestService {
|
||||
}
|
||||
}
|
||||
|
||||
async sendTestDigest(userId: string): Promise<TaskNotificationTestResult> {
|
||||
const user = await this.usersRepository.findOne({ where: { id: userId } });
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User was not found.');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const slot = this.currentSlot(now) ?? 'morning';
|
||||
const dateKey = this.dateKey(now);
|
||||
const { payload, taskCount, usedFallbackTask } =
|
||||
await this.buildDigestPayload(user.id, slot, dateKey, true);
|
||||
const result: TaskNotificationTestResult = {
|
||||
message: 'Task notification test processed.',
|
||||
date: dateKey,
|
||||
slot,
|
||||
taskCount,
|
||||
usedFallbackTask,
|
||||
mail: { sent: false },
|
||||
push: {
|
||||
enabled: false,
|
||||
subscriptionCount: 0,
|
||||
sentCount: 0,
|
||||
failedCount: 0,
|
||||
sent: false,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await this.mailService.sendTaskDigestEmail(user.email, {
|
||||
...payload,
|
||||
displayName: user.name ?? undefined,
|
||||
});
|
||||
result.mail.sent = true;
|
||||
} catch (error) {
|
||||
result.mail.error = this.errorMessage(error);
|
||||
this.logger.error(
|
||||
`Task test email could not be sent for user ${user.id}.`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const pushResult = await this.taskPushService.sendTaskDigest(
|
||||
user.id,
|
||||
payload,
|
||||
);
|
||||
result.push.enabled = pushResult.enabled;
|
||||
result.push.subscriptionCount = pushResult.subscriptionCount;
|
||||
result.push.sentCount = pushResult.sentCount;
|
||||
result.push.failedCount = pushResult.failedCount;
|
||||
result.push.sent = pushResult.sentCount > 0;
|
||||
} catch (error) {
|
||||
result.push.error = this.errorMessage(error);
|
||||
this.logger.error(
|
||||
`Task test push notification could not be sent for user ${user.id}.`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async processUserDigest(
|
||||
user: UserEntity,
|
||||
slot: TaskDigestSlot,
|
||||
dateKey: string,
|
||||
): Promise<void> {
|
||||
const tasks = await this.tasksRepository.find({
|
||||
where: {
|
||||
ownerId: user.id,
|
||||
deletedAt: IsNull(),
|
||||
completed: false,
|
||||
dueDate: LessThanOrEqual(dateKey),
|
||||
},
|
||||
order: { dueDate: 'ASC', createdAt: 'ASC' },
|
||||
});
|
||||
const overdueTasks = tasks.filter((task) => task.dueDate < dateKey);
|
||||
const todayTasks = tasks.filter((task) => task.dueDate === dateKey);
|
||||
|
||||
if (tasks.length > 0) {
|
||||
const digestPayload = {
|
||||
slot,
|
||||
date: dateKey,
|
||||
tasksUrl: this.tasksUrl(),
|
||||
todayTasks: todayTasks.map((task) => ({
|
||||
title: task.title,
|
||||
notes: task.notes ?? undefined,
|
||||
dueDate: task.dueDate,
|
||||
})),
|
||||
overdueTasks: overdueTasks.map((task) => ({
|
||||
title: task.title,
|
||||
notes: task.notes ?? undefined,
|
||||
dueDate: task.dueDate,
|
||||
})),
|
||||
};
|
||||
const { payload, taskCount } = await this.buildDigestPayload(
|
||||
user.id,
|
||||
slot,
|
||||
dateKey,
|
||||
false,
|
||||
);
|
||||
|
||||
if (taskCount > 0) {
|
||||
try {
|
||||
await this.mailService.sendTaskDigestEmail(user.email, {
|
||||
...digestPayload,
|
||||
...payload,
|
||||
displayName: user.name ?? undefined,
|
||||
});
|
||||
await this.taskPushService.sendTaskDigest(user.id, digestPayload);
|
||||
await this.taskPushService.sendTaskDigest(user.id, payload);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Task digest could not be sent for user ${user.id}.`,
|
||||
@@ -108,6 +185,59 @@ export class TaskDigestService {
|
||||
await this.usersRepository.save(user);
|
||||
}
|
||||
|
||||
private async buildDigestPayload(
|
||||
userId: string,
|
||||
slot: TaskDigestSlot,
|
||||
dateKey: string,
|
||||
includeFallbackTask: boolean,
|
||||
): Promise<TaskDigestPayloadResult> {
|
||||
const tasks = await this.tasksRepository.find({
|
||||
where: {
|
||||
ownerId: userId,
|
||||
deletedAt: IsNull(),
|
||||
completed: false,
|
||||
dueDate: LessThanOrEqual(dateKey),
|
||||
},
|
||||
order: { dueDate: 'ASC', createdAt: 'ASC' },
|
||||
});
|
||||
const overdueTasks = tasks.filter((task) => task.dueDate < dateKey);
|
||||
const todayTasks = tasks.filter((task) => task.dueDate === dateKey);
|
||||
const usedFallbackTask = includeFallbackTask && tasks.length === 0;
|
||||
const mappedTodayTasks = todayTasks.map((task) => this.toDigestItem(task));
|
||||
const mappedOverdueTasks = overdueTasks.map((task) =>
|
||||
this.toDigestItem(task),
|
||||
);
|
||||
|
||||
if (usedFallbackTask) {
|
||||
mappedTodayTasks.push({
|
||||
title: 'Test-Benachrichtigung',
|
||||
notes:
|
||||
'Diese Aufgabe wurde nur fuer den Benachrichtigungstest erzeugt.',
|
||||
dueDate: dateKey,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
payload: {
|
||||
slot,
|
||||
date: dateKey,
|
||||
tasksUrl: this.tasksUrl(),
|
||||
todayTasks: mappedTodayTasks,
|
||||
overdueTasks: mappedOverdueTasks,
|
||||
},
|
||||
taskCount: tasks.length + (usedFallbackTask ? 1 : 0),
|
||||
usedFallbackTask,
|
||||
};
|
||||
}
|
||||
|
||||
private toDigestItem(task: UserTaskEntity): TaskDigestEmailItem {
|
||||
return {
|
||||
title: task.title,
|
||||
notes: task.notes ?? undefined,
|
||||
dueDate: task.dueDate,
|
||||
};
|
||||
}
|
||||
|
||||
private shouldProcessUser(
|
||||
user: UserEntity,
|
||||
slot: TaskDigestSlot,
|
||||
@@ -205,4 +335,8 @@ export class TaskDigestService {
|
||||
|
||||
return `${clientUrl.replace(/\/$/, '')}/tasks`;
|
||||
}
|
||||
|
||||
private errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : 'Unknown error';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,13 @@ interface TaskDigestPushPayload {
|
||||
overdueTasks: TaskDigestEmailItem[];
|
||||
}
|
||||
|
||||
export interface TaskPushDigestResult {
|
||||
enabled: boolean;
|
||||
subscriptionCount: number;
|
||||
sentCount: number;
|
||||
failedCount: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TaskPushService {
|
||||
private readonly logger = new Logger(TaskPushService.name);
|
||||
@@ -89,34 +96,47 @@ export class TaskPushService {
|
||||
async sendTaskDigest(
|
||||
userId: string,
|
||||
payload: TaskDigestPushPayload,
|
||||
): Promise<void> {
|
||||
): Promise<TaskPushDigestResult> {
|
||||
if (!this.configureWebPush()) {
|
||||
this.logger.warn(
|
||||
'Task push digest skipped because VAPID keys are not configured.',
|
||||
);
|
||||
return;
|
||||
return {
|
||||
enabled: false,
|
||||
subscriptionCount: 0,
|
||||
sentCount: 0,
|
||||
failedCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const subscriptions = await this.subscriptionsRepository.find({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
const results = await Promise.all(
|
||||
subscriptions.map((subscription) =>
|
||||
this.sendToSubscription(subscription, payload),
|
||||
),
|
||||
);
|
||||
const sentCount = results.filter(Boolean).length;
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
subscriptionCount: subscriptions.length,
|
||||
sentCount,
|
||||
failedCount: subscriptions.length - sentCount,
|
||||
};
|
||||
}
|
||||
|
||||
private async sendToSubscription(
|
||||
subscription: TaskPushSubscriptionEntity,
|
||||
payload: TaskDigestPushPayload,
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await webPush.sendNotification(
|
||||
this.toPushSubscription(subscription),
|
||||
JSON.stringify(this.toNotificationPayload(payload)),
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const statusCode =
|
||||
typeof error === 'object' && error !== null && 'statusCode' in error
|
||||
@@ -125,13 +145,14 @@ export class TaskPushService {
|
||||
|
||||
if (statusCode === 404 || statusCode === 410) {
|
||||
await this.subscriptionsRepository.delete({ id: subscription.id });
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger.error(
|
||||
`Task push notification could not be sent to subscription ${subscription.id}.`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SaveTaskPushSubscriptionDto,
|
||||
} from './dto/task-push-subscription.dto';
|
||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||
import { TaskDigestService } from './task-digest.service';
|
||||
import { TaskPushService } from './task-push.service';
|
||||
import { TasksService } from './tasks.service';
|
||||
import type { AuthenticatedRequest } from '../auth/auth.types';
|
||||
@@ -26,6 +27,7 @@ import type { AuthenticatedRequest } from '../auth/auth.types';
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TasksController {
|
||||
constructor(
|
||||
private readonly taskDigestService: TaskDigestService,
|
||||
private readonly taskPushService: TaskPushService,
|
||||
private readonly tasksService: TasksService,
|
||||
) {}
|
||||
@@ -73,6 +75,11 @@ export class TasksController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('notifications/test')
|
||||
sendTestNotification(@Req() request: AuthenticatedRequest) {
|
||||
return this.taskDigestService.sendTestDigest(this.requireUserId(request));
|
||||
}
|
||||
|
||||
@Get(':taskId')
|
||||
getTask(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
|
||||
Reference in New Issue
Block a user