- Automation rules propose suggestions instead of creating tasks; notify per rule config; corrected varroa measurements refresh or resolve their suggestion - Inspection values can be taken over into the colony card (occupiedFrames 0-40, strength deprecated); warning counts use the same visibility rule as the notification list - Biotech chains: swarm check after queen cells, queen acceptance check, split schedules (raise own, queen cell, queen) - Season autopilot core with A1 inspection due, A2 varroa measurement, A3 recheck after treatment; colony sync and daily step - Frontend: suggestions page with groups, bulk accept, undo, paging, focus management and permission gating - Frontend: varroa traffic light from seasonal bands, sample size, one range request for the colony chart - Notifications: mark-read answers 404 for rows the user may no longer see Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
640 lines
21 KiB
TypeScript
640 lines
21 KiB
TypeScript
import { INestApplication } from '@nestjs/common';
|
|
import * as bcrypt from 'bcryptjs';
|
|
import request from 'supertest';
|
|
import { App } from 'supertest/types';
|
|
import { PrismaService } from '../src/prisma/prisma.service';
|
|
import { SuggestionsService } from '../src/suggestions/suggestions.service';
|
|
import type { ProposeInput } from '../src/suggestions/suggestion.types';
|
|
import { berlinToday } from '../src/common/dates';
|
|
import { login } from './utils/api';
|
|
import { createTestApp } from './utils/create-test-app';
|
|
import { drainDomainEvents } from './utils/domain-events';
|
|
|
|
/**
|
|
* The suggestion core against a real PostgreSQL: the dedupe key, the SQL
|
|
* status changes that free it, the plan tasks and the 30-day reopen window.
|
|
*/
|
|
describe('Suggestions (e2e)', () => {
|
|
let app: INestApplication<App>;
|
|
let prisma: PrismaService;
|
|
let service: SuggestionsService;
|
|
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
|
let apiaryId: string;
|
|
let colonyId: string;
|
|
let userId: string;
|
|
const roleIds: string[] = [];
|
|
const extraUserIds: string[] = [];
|
|
const password = 'SuperSecret123!';
|
|
const proposal = (anchor: string): ProposeInput => ({
|
|
ruleKey: 'SPLIT_SCHEDULE',
|
|
source: 'CHAIN',
|
|
mode: 'PLAN',
|
|
intent: 'SPLIT_PLAN',
|
|
relatedType: 'COLONY',
|
|
relatedId: colonyId,
|
|
colonyId,
|
|
reasonCode: `SUGGESTIONS_E2E_${suffix}`,
|
|
evidence: { colonyId, baseDate: '2026-05-01' },
|
|
requiredPermission: 'colonies:read',
|
|
plan: [
|
|
{ step: 'RAISE_CELLS_CHECK', offsetDays: 9 },
|
|
{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 },
|
|
],
|
|
anchor,
|
|
});
|
|
|
|
const propose = (input: ProposeInput) =>
|
|
prisma.$transaction((tx) => service.proposeIn(tx, input));
|
|
|
|
const statusOf = async (id: string) =>
|
|
(await prisma.suggestion.findUniqueOrThrow({ where: { id } })).status;
|
|
|
|
beforeAll(async () => {
|
|
app = await createTestApp();
|
|
prisma = app.get(PrismaService);
|
|
service = app.get(SuggestionsService);
|
|
const apiary = await prisma.apiary.create({
|
|
data: { name: `Suggestions e2e ${suffix}` },
|
|
});
|
|
apiaryId = apiary.id;
|
|
const colony = await prisma.colony.create({
|
|
data: {
|
|
code: `SUG-${suffix}`,
|
|
apiaryId,
|
|
colonyType: 'PRODUCTION_COLONY',
|
|
},
|
|
});
|
|
colonyId = colony.id;
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
email: `suggestions-e2e-${suffix}@example.com`,
|
|
passwordHash: 'x',
|
|
name: 'Suggestions e2e',
|
|
role: { connect: { key: 'STAFF' } },
|
|
},
|
|
});
|
|
userId = user.id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await clearSuggestions();
|
|
const inspectionIds = (
|
|
await prisma.inspection.findMany({
|
|
where: { colonyId },
|
|
select: { id: true },
|
|
})
|
|
).map((inspection) => inspection.id);
|
|
await prisma.domainEvent.deleteMany({
|
|
where: { aggregateId: { in: inspectionIds } },
|
|
});
|
|
await prisma.calendarSyncOutboxEntry.deleteMany({
|
|
where: { sourceId: { in: inspectionIds } },
|
|
});
|
|
await prisma.inspection.deleteMany({ where: { colonyId } });
|
|
await prisma.colony.delete({ where: { id: colonyId } });
|
|
await prisma.apiary.delete({ where: { id: apiaryId } });
|
|
await prisma.refreshToken.deleteMany({
|
|
where: { userId: { in: extraUserIds } },
|
|
});
|
|
await prisma.auditLog.deleteMany({
|
|
where: { userId: { in: [userId, ...extraUserIds] } },
|
|
});
|
|
await prisma.user.deleteMany({
|
|
where: { id: { in: [userId, ...extraUserIds] } },
|
|
});
|
|
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
|
|
await app.close();
|
|
});
|
|
|
|
/** The colony's suggestions with what hangs on them: tasks, notifications, audit. */
|
|
async function clearSuggestions(): Promise<void> {
|
|
const ids = (
|
|
await prisma.suggestion.findMany({
|
|
where: { colonyId },
|
|
select: { id: true },
|
|
})
|
|
).map((suggestion) => suggestion.id);
|
|
await prisma.task.deleteMany({
|
|
where: { sourceSuggestion: { colonyId } },
|
|
});
|
|
await prisma.notification.deleteMany({
|
|
where: { relatedType: 'Suggestion', relatedId: { in: ids } },
|
|
});
|
|
await prisma.auditLog.deleteMany({
|
|
where: {
|
|
OR: [
|
|
{ entityType: 'Suggestion', entityId: { in: ids } },
|
|
{
|
|
entityType: 'AutomationRule',
|
|
afterData: { path: ['relatedId'], equals: colonyId },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
await prisma.suggestion.deleteMany({ where: { colonyId } });
|
|
}
|
|
|
|
afterEach(clearSuggestions);
|
|
|
|
it('proposes once per anchor and writes an audit entry', async () => {
|
|
const created = await propose(proposal('a1'));
|
|
expect(created).not.toBeNull();
|
|
expect(created?.dedupeKey).toBe(`SPLIT_SCHEDULE:${colonyId}:a1`);
|
|
expect(
|
|
await prisma.auditLog.count({
|
|
where: { entityType: 'Suggestion', entityId: created!.id },
|
|
}),
|
|
).toBe(1);
|
|
|
|
// Same anchor: the key exists. A second intent check would also stop it.
|
|
expect(await propose(proposal('a1'))).toBeNull();
|
|
});
|
|
|
|
it('allows one open suggestion per intent and colony', async () => {
|
|
expect(await propose(proposal('a1'))).not.toBeNull();
|
|
expect(await propose(proposal('a2'))).toBeNull();
|
|
expect(
|
|
await propose({
|
|
...proposal('a3'),
|
|
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
|
intent: 'QUEEN_ACCEPTANCE',
|
|
plan: null,
|
|
}),
|
|
).not.toBeNull();
|
|
});
|
|
|
|
it('is stopped by an open task of the same intent', async () => {
|
|
const first = (await propose(proposal('a1')))!;
|
|
await service.accept(first.id, { dueDate: '2026-05-12' }, userId);
|
|
// The accepted plan left open tasks of this intent.
|
|
expect(await propose(proposal('a2'))).toBeNull();
|
|
});
|
|
|
|
it('expireDue frees the key, dismiss keeps it', async () => {
|
|
const dismissed = (await propose(proposal('d1')))!;
|
|
await service.dismiss(dismissed.id, 'nein', userId);
|
|
expect(await statusOf(dismissed.id)).toBe('DISMISSED');
|
|
expect(await propose(proposal('d1'))).toBeNull();
|
|
|
|
const expiring = (await propose({
|
|
...proposal('e1'),
|
|
expiresAt: '2026-05-01',
|
|
}))!;
|
|
expect(await service.expireDue('2026-05-02')).toBeGreaterThanOrEqual(1);
|
|
const expired = await prisma.suggestion.findUniqueOrThrow({
|
|
where: { id: expiring.id },
|
|
});
|
|
expect(expired.status).toBe('EXPIRED');
|
|
expect(expired.dedupeKey).toBeNull();
|
|
expect(await propose(proposal('e1'))).not.toBeNull();
|
|
});
|
|
|
|
it('resolveIn frees the key in the same statement', async () => {
|
|
const created = (await propose(proposal('r1')))!;
|
|
|
|
expect(
|
|
await prisma.$transaction((tx) =>
|
|
service.resolveIn(tx, [created.id], 'Bedingung entfällt'),
|
|
),
|
|
).toBe(1);
|
|
|
|
const resolved = await prisma.suggestion.findUniqueOrThrow({
|
|
where: { id: created.id },
|
|
});
|
|
expect(resolved).toMatchObject({
|
|
status: 'RESOLVED',
|
|
dedupeKey: null,
|
|
decisionNote: 'Bedingung entfällt',
|
|
});
|
|
expect(await propose(proposal('r1'))).not.toBeNull();
|
|
});
|
|
|
|
it('accept creates the plan tasks shifted to the chosen day', async () => {
|
|
const created = (await propose(proposal('p1')))!;
|
|
|
|
const result = await service.accept(
|
|
created.id,
|
|
{ dueDate: '2026-05-12' },
|
|
userId,
|
|
);
|
|
|
|
expect(
|
|
result.tasks.map((t) => t.dueDate?.toISOString().slice(0, 10)),
|
|
).toEqual(['2026-05-12', '2026-05-24']);
|
|
expect(result.tasks[1].description).toContain('bis 27.05.');
|
|
expect(result.tasks.every((t) => t.sourceSuggestionId === created.id)).toBe(
|
|
true,
|
|
);
|
|
expect(await statusOf(created.id)).toBe('ACCEPTED');
|
|
await expect(service.accept(created.id, {}, userId)).rejects.toMatchObject({
|
|
response: { status: 'ACCEPTED' },
|
|
});
|
|
});
|
|
|
|
it('bulkAccept rolls everything back when one suggestion is not open', async () => {
|
|
const open = (await propose(proposal('b1')))!;
|
|
const other = (await propose({
|
|
...proposal('b2'),
|
|
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
|
intent: 'QUEEN_ACCEPTANCE',
|
|
plan: null,
|
|
dueDate: '2026-05-20',
|
|
}))!;
|
|
await service.dismiss(other.id, null, userId);
|
|
|
|
await expect(
|
|
service.bulkAccept([open.id, other.id], userId),
|
|
).rejects.toMatchObject({ status: 409 });
|
|
|
|
expect(await statusOf(open.id)).toBe('OPEN');
|
|
expect(
|
|
await prisma.task.count({ where: { sourceSuggestionId: open.id } }),
|
|
).toBe(0);
|
|
});
|
|
|
|
it('reopens within 30 days and answers 409 after that', async () => {
|
|
const created = (await propose(proposal('o1')))!;
|
|
await service.dismiss(created.id, null, userId);
|
|
|
|
const reopened = await service.reopen(created.id, userId);
|
|
expect(reopened).toMatchObject({ status: 'OPEN', decidedAt: null });
|
|
|
|
await service.dismiss(created.id, null, userId);
|
|
await prisma.$executeRaw`UPDATE "Suggestion" SET "decidedAt" = now() - interval '31 days' WHERE id = ${created.id}`;
|
|
await expect(service.reopen(created.id, userId)).rejects.toMatchObject({
|
|
status: 409,
|
|
});
|
|
expect(await statusOf(created.id)).toBe('DISMISSED');
|
|
});
|
|
|
|
it('refuses to reopen while the same intent is open again', async () => {
|
|
const dismissed = (await propose(proposal('q1')))!;
|
|
await service.dismiss(dismissed.id, null, userId);
|
|
expect(await propose(proposal('q2'))).not.toBeNull();
|
|
|
|
await expect(service.reopen(dismissed.id, userId)).rejects.toMatchObject({
|
|
status: 409,
|
|
});
|
|
expect(await statusOf(dismissed.id)).toBe('DISMISSED');
|
|
});
|
|
|
|
it('answers 400 for an unknown or inactive assignee, never 500', async () => {
|
|
const created = (await propose(proposal('u1')))!;
|
|
const inactive = await prisma.user.create({
|
|
data: {
|
|
email: `suggestions-e2e-inactive-${suffix}@example.com`,
|
|
passwordHash: 'x',
|
|
name: 'Inactive',
|
|
isActive: false,
|
|
role: { connect: { key: 'STAFF' } },
|
|
},
|
|
});
|
|
try {
|
|
for (const assignedUserId of ['no-such-user', inactive.id]) {
|
|
await expect(
|
|
service.accept(created.id, { assignedUserId }, userId),
|
|
).rejects.toMatchObject({ status: 400 });
|
|
}
|
|
expect(await statusOf(created.id)).toBe('OPEN');
|
|
const ok = await service.accept(
|
|
created.id,
|
|
{ assignedUserId: userId },
|
|
userId,
|
|
);
|
|
expect(ok.tasks.every((t) => t.assignedUserId === userId)).toBe(true);
|
|
} finally {
|
|
await prisma.task.deleteMany({
|
|
where: { sourceSuggestionId: created.id },
|
|
});
|
|
await prisma.user.delete({ where: { id: inactive.id } });
|
|
}
|
|
});
|
|
|
|
describe('concurrency', () => {
|
|
it('accept against dismiss on one suggestion: one wins, the other gets 409 with the status', async () => {
|
|
const created = (await propose(proposal('c1')))!;
|
|
|
|
const settled = await Promise.allSettled([
|
|
service.accept(created.id, { dueDate: '2026-05-12' }, userId),
|
|
service.dismiss(created.id, null, userId),
|
|
]);
|
|
|
|
expect(settled.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
|
|
const [failure] = settled.filter(
|
|
(r): r is PromiseRejectedResult => r.status === 'rejected',
|
|
);
|
|
expect(failure.reason).toMatchObject({ status: 409 });
|
|
const winner = await statusOf(created.id);
|
|
expect(['ACCEPTED', 'DISMISSED']).toContain(winner);
|
|
expect(
|
|
(failure.reason as { response: { status: string } }).response.status,
|
|
).toBe(winner);
|
|
});
|
|
|
|
it('two proposals for one colony and intent from different rules leave one row', async () => {
|
|
const results = await Promise.all([
|
|
propose({ ...proposal('k1'), ruleKey: 'SPLIT_SCHEDULE' }),
|
|
propose({
|
|
...proposal('k2'),
|
|
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
|
source: 'RULE',
|
|
}),
|
|
propose({
|
|
...proposal('k3'),
|
|
ruleKey: 'INSPECTION_INTERVAL',
|
|
source: 'AUTOPILOT',
|
|
}),
|
|
]);
|
|
|
|
expect(results.filter((r) => r !== null)).toHaveLength(1);
|
|
expect(
|
|
await prisma.suggestion.count({
|
|
where: { colonyId, intent: 'SPLIT_PLAN', status: 'OPEN' },
|
|
}),
|
|
).toBe(1);
|
|
});
|
|
|
|
it('overlapping bulkAccept and accept do not deadlock and stay consistent', async () => {
|
|
const ids: string[] = [];
|
|
for (const [index, intent] of (
|
|
['SPLIT_PLAN', 'QUEEN_ACCEPTANCE', 'FEED'] as const
|
|
).entries()) {
|
|
const created = await propose({
|
|
...proposal(`ov${index}`),
|
|
ruleKey:
|
|
intent === 'SPLIT_PLAN'
|
|
? 'SPLIT_SCHEDULE'
|
|
: 'QUEEN_ACCEPTANCE_CHECK',
|
|
intent,
|
|
plan: null,
|
|
dueDate: '2026-05-20',
|
|
});
|
|
ids.push(created!.id);
|
|
}
|
|
|
|
const settled = await Promise.allSettled([
|
|
service.bulkAccept([ids[0], ids[1], ids[2]], userId),
|
|
service.accept(ids[1], {}, userId),
|
|
service.bulkAccept([ids[2], ids[1]], userId),
|
|
]);
|
|
|
|
for (const result of settled) {
|
|
if (result.status === 'rejected') {
|
|
expect(result.reason).toMatchObject({ status: 409 });
|
|
}
|
|
}
|
|
expect(settled.some((r) => r.status === 'fulfilled')).toBe(true);
|
|
for (const id of ids) {
|
|
// Accepted exactly once, or not at all: never a task without it.
|
|
const accepted = (await statusOf(id)) === 'ACCEPTED';
|
|
expect(
|
|
await prisma.task.count({ where: { sourceSuggestionId: id } }),
|
|
).toBe(accepted ? 1 : 0);
|
|
}
|
|
});
|
|
});
|
|
|
|
it('answers 409 for accept and reopen once the colony has ended', async () => {
|
|
const open = (await propose(proposal('end1')))!;
|
|
const dismissed = (await propose({
|
|
...proposal('end2'),
|
|
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
|
intent: 'QUEEN_ACCEPTANCE',
|
|
plan: null,
|
|
dueDate: '2026-05-20',
|
|
}))!;
|
|
await service.dismiss(dismissed.id, null, userId);
|
|
await prisma.colony.update({
|
|
where: { id: colonyId },
|
|
data: { status: 'SOLD' },
|
|
});
|
|
try {
|
|
await expect(service.accept(open.id, {}, userId)).rejects.toMatchObject({
|
|
status: 409,
|
|
});
|
|
await expect(service.reopen(dismissed.id, userId)).rejects.toMatchObject({
|
|
status: 409,
|
|
});
|
|
} finally {
|
|
await prisma.colony.update({
|
|
where: { id: colonyId },
|
|
data: { status: 'ACTIVE' },
|
|
});
|
|
}
|
|
});
|
|
|
|
it('proposes nothing for a colony that does not exist', async () => {
|
|
expect(
|
|
await propose({ ...proposal('nc1'), colonyId: 'no-such-colony' }),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('bulkAccept answers 400 naming the DO suggestions without a date', async () => {
|
|
const undated = (await propose({
|
|
...proposal('bd1'),
|
|
mode: 'DO',
|
|
plan: null,
|
|
dueDate: null,
|
|
}))!;
|
|
|
|
await expect(
|
|
service.bulkAccept([undated.id], userId),
|
|
).rejects.toMatchObject({
|
|
status: 400,
|
|
response: { issues: [{ field: 'ids', ids: [undated.id] }] },
|
|
});
|
|
expect(await statusOf(undated.id)).toBe('OPEN');
|
|
});
|
|
|
|
it('obsoleteForColonyIn closes open suggestions and keeps the key', async () => {
|
|
const created = (await propose(proposal('x1')))!;
|
|
|
|
expect(
|
|
await prisma.$transaction((tx) =>
|
|
service.obsoleteForColonyIn(tx, colonyId),
|
|
),
|
|
).toBe(1);
|
|
|
|
const obsolete = await prisma.suggestion.findUniqueOrThrow({
|
|
where: { id: created.id },
|
|
});
|
|
expect(obsolete.status).toBe('OBSOLETE');
|
|
expect(obsolete.dedupeKey).not.toBeNull();
|
|
});
|
|
describe('rule suggestions', () => {
|
|
let writer: { id: string; token: string };
|
|
let withoutInspections: { id: string };
|
|
let withoutTasks: { id: string };
|
|
|
|
async function createUser(
|
|
label: string,
|
|
permissions: string[],
|
|
): Promise<{ id: string; token: string }> {
|
|
const role = await prisma.role.create({
|
|
data: {
|
|
key: `SUG_RULES_${label.toUpperCase()}_${suffix}`,
|
|
name: `Suggestions rules ${label}`,
|
|
permissions: {
|
|
create: permissions.map((key) => ({
|
|
permission: { connect: { key } },
|
|
})),
|
|
},
|
|
},
|
|
});
|
|
roleIds.push(role.id);
|
|
const email = `suggestions-rules-${label}-${suffix}@example.com`;
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
email,
|
|
passwordHash: await bcrypt.hash(password, 12),
|
|
name: `Suggestions rules ${label}`,
|
|
role: { connect: { id: role.id } },
|
|
},
|
|
});
|
|
extraUserIds.push(user.id);
|
|
return {
|
|
id: user.id,
|
|
token: (await login(app, email, password)).accessToken,
|
|
};
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
writer = await createUser('writer', [
|
|
'inspections:create',
|
|
'inspections:read',
|
|
'tasks:read',
|
|
]);
|
|
withoutInspections = await createUser('noinsp', ['tasks:read']);
|
|
withoutTasks = await createUser('notasks', ['inspections:read']);
|
|
});
|
|
|
|
/** Records an inspection and runs the handlers of its event. */
|
|
async function inspect(body: object): Promise<string> {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/api/v1/inspections')
|
|
.set('Authorization', `Bearer ${writer.token}`)
|
|
.send({ colonyId, date: new Date().toISOString(), ...body })
|
|
.expect(201);
|
|
const id = (response.body as { id: string }).id;
|
|
await drainDomainEvents(app, [id]);
|
|
return id;
|
|
}
|
|
|
|
const queenless = { queenSeen: false, eggsPresent: false };
|
|
const ruleSuggestions = () =>
|
|
prisma.suggestion.findMany({
|
|
where: { colonyId, ruleKey: 'QUEENLESS_NO_EGGS' },
|
|
});
|
|
|
|
it('an inspection without queen and eggs leaves one OPEN suggestion and no task', async () => {
|
|
const inspectionId = await inspect(queenless);
|
|
|
|
const suggestions = await ruleSuggestions();
|
|
expect(suggestions).toHaveLength(1);
|
|
expect(suggestions[0]).toMatchObject({
|
|
status: 'OPEN',
|
|
source: 'RULE',
|
|
mode: 'PLAN',
|
|
intent: 'QUEEN_CHECK',
|
|
dedupeKey: `QUEENLESS_NO_EGGS:${colonyId}:${inspectionId}`,
|
|
});
|
|
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(
|
|
0,
|
|
);
|
|
});
|
|
|
|
it('a dismissed suggestion is not proposed again for the same inspection', async () => {
|
|
const inspectionId = await inspect(queenless);
|
|
const [first] = await ruleSuggestions();
|
|
await service.dismiss(first.id, null, userId);
|
|
|
|
// The delivery runs again, as after a retry or a catch-up.
|
|
await prisma.domainEvent.updateMany({
|
|
where: { aggregateId: inspectionId },
|
|
data: { status: 'PENDING', processedAt: null, claimedAt: null },
|
|
});
|
|
await prisma.domainEventDelivery.updateMany({
|
|
where: {
|
|
handler: 'automation.inspection-rules',
|
|
event: { aggregateId: inspectionId },
|
|
},
|
|
data: { status: 'PENDING', attempts: 0, completedAt: null },
|
|
});
|
|
await drainDomainEvents(app, [inspectionId]);
|
|
|
|
const suggestions = await ruleSuggestions();
|
|
expect(suggestions).toHaveLength(1);
|
|
expect(suggestions[0].status).toBe('DISMISSED');
|
|
});
|
|
|
|
it('a later inspection with the same finding proposes again', async () => {
|
|
await inspect(queenless);
|
|
const [first] = await ruleSuggestions();
|
|
await service.dismiss(first.id, null, userId);
|
|
|
|
await inspect(queenless);
|
|
|
|
const suggestions = await ruleSuggestions();
|
|
expect(suggestions.map((suggestion) => suggestion.status).sort()).toEqual(
|
|
['DISMISSED', 'OPEN'],
|
|
);
|
|
});
|
|
|
|
it('accepting a rule suggestion without dueInDays creates a task due today', async () => {
|
|
await inspect(queenless);
|
|
const [open] = await ruleSuggestions();
|
|
expect(open.dueDate).toBeNull();
|
|
|
|
const result = await service.accept(open.id, {}, userId);
|
|
|
|
expect(result.tasks).toHaveLength(1);
|
|
expect(result.tasks[0].dueDate?.toISOString().slice(0, 10)).toBe(
|
|
berlinToday(),
|
|
);
|
|
expect(result.tasks[0]).toMatchObject({
|
|
sourceSuggestionId: open.id,
|
|
sourceAutomationTrigger: 'QUEENLESS_NO_EGGS',
|
|
priority: 'HIGH',
|
|
});
|
|
});
|
|
|
|
it('a chain suggestion proposed with notify true notifies like a rule suggestion', async () => {
|
|
const created = (await prisma.$transaction((tx) =>
|
|
service.proposeIn(tx, {
|
|
...proposal('notify-chain'),
|
|
notify: true,
|
|
requiredPermission: 'colonies:read',
|
|
}),
|
|
))!;
|
|
|
|
const rows = await prisma.notification.findMany({
|
|
where: { relatedType: 'Suggestion', relatedId: created.id },
|
|
});
|
|
expect(rows.length).toBeGreaterThan(0);
|
|
expect(rows.every((row) => row.category === 'SUGGESTION')).toBe(true);
|
|
expect(rows.every((row) => row.priority === 'WARNUNG')).toBe(true);
|
|
expect(rows.every((row) => row.link === `/colonies/${colonyId}`)).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('notify writes a WARNUNG only to holders of tasks:read and inspections:read', async () => {
|
|
await inspect(queenless);
|
|
const [open] = await ruleSuggestions();
|
|
|
|
const rows = await prisma.notification.findMany({
|
|
where: { relatedType: 'Suggestion', relatedId: open.id },
|
|
});
|
|
|
|
const recipients = new Set(rows.map((row) => row.userId));
|
|
expect(recipients.has(writer.id)).toBe(true);
|
|
expect(recipients.has(withoutInspections.id)).toBe(false);
|
|
expect(recipients.has(withoutTasks.id)).toBe(false);
|
|
expect(rows.every((row) => row.priority === 'WARNUNG')).toBe(true);
|
|
expect(
|
|
rows.every((row) => row.message.startsWith(`SUG-${suffix}: `)),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
});
|