test
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { UserEntity } from '../auth/user.entity';
|
import { UserEntity } from '../auth/user.entity';
|
||||||
import { MailService } from '../mail/mail.service';
|
import { MailService } from '../mail/mail.service';
|
||||||
@@ -20,7 +21,14 @@ describe('TaskDigestService', () => {
|
|||||||
sendTaskDigestEmail: jest.fn().mockResolvedValue(undefined),
|
sendTaskDigestEmail: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
taskPushService = {
|
taskPushService = {
|
||||||
sendTaskDigest: jest.fn().mockResolvedValue(undefined),
|
sendTaskDigest: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
subscriptionCount: 1,
|
||||||
|
sentCount: 1,
|
||||||
|
failedCount: 0,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
service = new TaskDigestService(
|
service = new TaskDigestService(
|
||||||
usersRepository as never,
|
usersRepository as never,
|
||||||
@@ -154,6 +162,65 @@ describe('TaskDigestService', () => {
|
|||||||
expect(taskPushService.sendTaskDigest).not.toHaveBeenCalled();
|
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> = {}) {
|
async function saveUser(overrides: Partial<UserEntity> = {}) {
|
||||||
return usersRepository.save({
|
return usersRepository.save({
|
||||||
id: overrides.id ?? 'owner-1',
|
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 { ConfigService } from '@nestjs/config';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { IsNull, LessThanOrEqual, Repository } from 'typeorm';
|
import { IsNull, LessThanOrEqual, Repository } from 'typeorm';
|
||||||
import { UserEntity } from '../auth/user.entity';
|
import { UserEntity } from '../auth/user.entity';
|
||||||
import { MailService } from '../mail/mail.service';
|
import { MailService } from '../mail/mail.service';
|
||||||
|
import type { TaskDigestEmailItem } from '../mail/mail.types';
|
||||||
import { TaskPushService } from './task-push.service';
|
import { TaskPushService } from './task-push.service';
|
||||||
import { UserTaskEntity } from './user-task.entity';
|
import { UserTaskEntity } from './user-task.entity';
|
||||||
import type { TaskDigestSlot } from './task-digest.types';
|
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()
|
@Injectable()
|
||||||
export class TaskDigestService {
|
export class TaskDigestService {
|
||||||
private readonly logger = new Logger(TaskDigestService.name);
|
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(
|
private async processUserDigest(
|
||||||
user: UserEntity,
|
user: UserEntity,
|
||||||
slot: TaskDigestSlot,
|
slot: TaskDigestSlot,
|
||||||
dateKey: string,
|
dateKey: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const tasks = await this.tasksRepository.find({
|
const { payload, taskCount } = await this.buildDigestPayload(
|
||||||
where: {
|
user.id,
|
||||||
ownerId: user.id,
|
slot,
|
||||||
deletedAt: IsNull(),
|
dateKey,
|
||||||
completed: false,
|
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,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
|
if (taskCount > 0) {
|
||||||
try {
|
try {
|
||||||
await this.mailService.sendTaskDigestEmail(user.email, {
|
await this.mailService.sendTaskDigestEmail(user.email, {
|
||||||
...digestPayload,
|
...payload,
|
||||||
displayName: user.name ?? undefined,
|
displayName: user.name ?? undefined,
|
||||||
});
|
});
|
||||||
await this.taskPushService.sendTaskDigest(user.id, digestPayload);
|
await this.taskPushService.sendTaskDigest(user.id, payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Task digest could not be sent for user ${user.id}.`,
|
`Task digest could not be sent for user ${user.id}.`,
|
||||||
@@ -108,6 +185,59 @@ export class TaskDigestService {
|
|||||||
await this.usersRepository.save(user);
|
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(
|
private shouldProcessUser(
|
||||||
user: UserEntity,
|
user: UserEntity,
|
||||||
slot: TaskDigestSlot,
|
slot: TaskDigestSlot,
|
||||||
@@ -205,4 +335,8 @@ export class TaskDigestService {
|
|||||||
|
|
||||||
return `${clientUrl.replace(/\/$/, '')}/tasks`;
|
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[];
|
overdueTasks: TaskDigestEmailItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskPushDigestResult {
|
||||||
|
enabled: boolean;
|
||||||
|
subscriptionCount: number;
|
||||||
|
sentCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TaskPushService {
|
export class TaskPushService {
|
||||||
private readonly logger = new Logger(TaskPushService.name);
|
private readonly logger = new Logger(TaskPushService.name);
|
||||||
@@ -89,34 +96,47 @@ export class TaskPushService {
|
|||||||
async sendTaskDigest(
|
async sendTaskDigest(
|
||||||
userId: string,
|
userId: string,
|
||||||
payload: TaskDigestPushPayload,
|
payload: TaskDigestPushPayload,
|
||||||
): Promise<void> {
|
): Promise<TaskPushDigestResult> {
|
||||||
if (!this.configureWebPush()) {
|
if (!this.configureWebPush()) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
'Task push digest skipped because VAPID keys are not configured.',
|
'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({
|
const subscriptions = await this.subscriptionsRepository.find({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
});
|
});
|
||||||
|
const results = await Promise.all(
|
||||||
await Promise.all(
|
|
||||||
subscriptions.map((subscription) =>
|
subscriptions.map((subscription) =>
|
||||||
this.sendToSubscription(subscription, payload),
|
this.sendToSubscription(subscription, payload),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const sentCount = results.filter(Boolean).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
subscriptionCount: subscriptions.length,
|
||||||
|
sentCount,
|
||||||
|
failedCount: subscriptions.length - sentCount,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendToSubscription(
|
private async sendToSubscription(
|
||||||
subscription: TaskPushSubscriptionEntity,
|
subscription: TaskPushSubscriptionEntity,
|
||||||
payload: TaskDigestPushPayload,
|
payload: TaskDigestPushPayload,
|
||||||
): Promise<void> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await webPush.sendNotification(
|
await webPush.sendNotification(
|
||||||
this.toPushSubscription(subscription),
|
this.toPushSubscription(subscription),
|
||||||
JSON.stringify(this.toNotificationPayload(payload)),
|
JSON.stringify(this.toNotificationPayload(payload)),
|
||||||
);
|
);
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const statusCode =
|
const statusCode =
|
||||||
typeof error === 'object' && error !== null && 'statusCode' in error
|
typeof error === 'object' && error !== null && 'statusCode' in error
|
||||||
@@ -125,13 +145,14 @@ export class TaskPushService {
|
|||||||
|
|
||||||
if (statusCode === 404 || statusCode === 410) {
|
if (statusCode === 404 || statusCode === 410) {
|
||||||
await this.subscriptionsRepository.delete({ id: subscription.id });
|
await this.subscriptionsRepository.delete({ id: subscription.id });
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Task push notification could not be sent to subscription ${subscription.id}.`,
|
`Task push notification could not be sent to subscription ${subscription.id}.`,
|
||||||
error instanceof Error ? error.stack : undefined,
|
error instanceof Error ? error.stack : undefined,
|
||||||
);
|
);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
SaveTaskPushSubscriptionDto,
|
SaveTaskPushSubscriptionDto,
|
||||||
} from './dto/task-push-subscription.dto';
|
} from './dto/task-push-subscription.dto';
|
||||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||||
|
import { TaskDigestService } from './task-digest.service';
|
||||||
import { TaskPushService } from './task-push.service';
|
import { TaskPushService } from './task-push.service';
|
||||||
import { TasksService } from './tasks.service';
|
import { TasksService } from './tasks.service';
|
||||||
import type { AuthenticatedRequest } from '../auth/auth.types';
|
import type { AuthenticatedRequest } from '../auth/auth.types';
|
||||||
@@ -26,6 +27,7 @@ import type { AuthenticatedRequest } from '../auth/auth.types';
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class TasksController {
|
export class TasksController {
|
||||||
constructor(
|
constructor(
|
||||||
|
private readonly taskDigestService: TaskDigestService,
|
||||||
private readonly taskPushService: TaskPushService,
|
private readonly taskPushService: TaskPushService,
|
||||||
private readonly tasksService: TasksService,
|
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')
|
@Get(':taskId')
|
||||||
getTask(
|
getTask(
|
||||||
@Req() request: AuthenticatedRequest,
|
@Req() request: AuthenticatedRequest,
|
||||||
|
|||||||
Reference in New Issue
Block a user