- 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]>
639 lines
20 KiB
TypeScript
639 lines
20 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 type { ProposeInput } from '../src/suggestions/suggestion.types';
|
|
import { login } from './utils/api';
|
|
import { createTestApp } from './utils/create-test-app';
|
|
import { drainDomainEvents } from './utils/domain-events';
|
|
import { proposeForTest } from './utils/suggestions';
|
|
|
|
interface ViewBody {
|
|
id: string;
|
|
title: string;
|
|
reasons: string[];
|
|
colonyCode: string | null;
|
|
apiaryName: string | null;
|
|
status: string;
|
|
}
|
|
|
|
const VIEW_KEYS = [
|
|
'apiaryId',
|
|
'apiaryName',
|
|
'cleanup',
|
|
'colonyCode',
|
|
'colonyId',
|
|
'createdAt',
|
|
'decidedAt',
|
|
'decisionNote',
|
|
'dueDate',
|
|
'expiresAt',
|
|
'id',
|
|
'intent',
|
|
'mode',
|
|
'plan',
|
|
'priority',
|
|
'reasons',
|
|
'relatedId',
|
|
'relatedType',
|
|
'ruleKey',
|
|
'source',
|
|
'status',
|
|
'title',
|
|
];
|
|
const INTERNAL_KEYS = [
|
|
'evidence',
|
|
'dedupeKey',
|
|
'requiredPermission',
|
|
'decidedByUserId',
|
|
'reasonCode',
|
|
'sourceEventId',
|
|
];
|
|
|
|
function expectViewShape(view: object): void {
|
|
expect(Object.keys(view).sort()).toEqual(VIEW_KEYS);
|
|
for (const key of INTERNAL_KEYS) {
|
|
expect(view).not.toHaveProperty(key);
|
|
}
|
|
}
|
|
|
|
/** The suggestion routes against a real PostgreSQL, as real roles. */
|
|
describe('Suggestions API (e2e)', () => {
|
|
let app: INestApplication<App>;
|
|
let prisma: PrismaService;
|
|
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
|
const password = 'SuperSecret123!';
|
|
const base = '/api/v1/suggestions';
|
|
const ADMIN_PERMISSIONS = [
|
|
'tasks:read',
|
|
'tasks:create',
|
|
'tasks:update',
|
|
'inspections:read',
|
|
'varroa:read',
|
|
'colonies:read',
|
|
];
|
|
let apiaryId: string;
|
|
let colonyId: string;
|
|
let inactiveUserId: string;
|
|
const roleIds: string[] = [];
|
|
const userIds: string[] = [];
|
|
const tokens: Record<
|
|
'full' | 'noVarroa' | 'readOnly' | 'noUpdate' | 'noTasks',
|
|
string
|
|
> = { full: '', noVarroa: '', readOnly: '', noUpdate: '', noTasks: '' };
|
|
let keyCounter = 0;
|
|
const nextKey = () => `suggestions-api-${suffix}-${(keyCounter += 1)}-key`;
|
|
|
|
const proposal = (anchor: string): ProposeInput => ({
|
|
ruleKey: 'INSPECTION_INTERVAL',
|
|
source: 'RULE',
|
|
mode: 'DO',
|
|
intent: 'INSPECT',
|
|
relatedType: 'COLONY',
|
|
relatedId: colonyId,
|
|
colonyId,
|
|
reasonCode: `SUGGESTIONS_API_${suffix}`,
|
|
evidence: { colonyId },
|
|
requiredPermission: 'inspections:read',
|
|
dueDate: '2026-05-12',
|
|
anchor,
|
|
});
|
|
|
|
const get = (token: string, path: string) =>
|
|
request(app.getHttpServer())
|
|
.get(`${base}${path}`)
|
|
.set('Authorization', `Bearer ${token}`);
|
|
|
|
const post = (token: string, path: string, body: object = {}, key?: string) =>
|
|
request(app.getHttpServer())
|
|
.post(`${base}${path}`)
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.set('Idempotency-Key', key ?? nextKey())
|
|
.send(body);
|
|
|
|
const tasksOf = (suggestionId: string) =>
|
|
prisma.task.findMany({ where: { sourceSuggestionId: suggestionId } });
|
|
|
|
async function createUser(
|
|
label: string,
|
|
permissions: string[],
|
|
isActive = true,
|
|
): Promise<{ id: string; token: string }> {
|
|
const role = await prisma.role.create({
|
|
data: {
|
|
key: `SUGGESTIONS_API_${label.toUpperCase()}_${suffix}`,
|
|
name: `Suggestions API ${label}`,
|
|
permissions: {
|
|
create: permissions.map((key) => ({
|
|
permission: { connect: { key } },
|
|
})),
|
|
},
|
|
},
|
|
});
|
|
roleIds.push(role.id);
|
|
const email = `suggestions-api-${label}-${suffix}@example.com`;
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
email,
|
|
passwordHash: await bcrypt.hash(password, 12),
|
|
name: `Suggestions API ${label}`,
|
|
isActive,
|
|
role: { connect: { id: role.id } },
|
|
},
|
|
});
|
|
userIds.push(user.id);
|
|
return {
|
|
id: user.id,
|
|
token: isActive ? (await login(app, email, password)).accessToken : '',
|
|
};
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
app = await createTestApp();
|
|
prisma = app.get(PrismaService);
|
|
apiaryId = (
|
|
await prisma.apiary.create({ data: { name: `Sug API ${suffix}` } })
|
|
).id;
|
|
colonyId = (
|
|
await prisma.colony.create({
|
|
data: {
|
|
code: `SA-${suffix}`,
|
|
apiaryId,
|
|
colonyType: 'PRODUCTION_COLONY',
|
|
},
|
|
})
|
|
).id;
|
|
tokens.full = (await createUser('full', ADMIN_PERMISSIONS)).token;
|
|
tokens.noVarroa = (
|
|
await createUser(
|
|
'novarroa',
|
|
ADMIN_PERMISSIONS.filter((key) => key !== 'varroa:read'),
|
|
)
|
|
).token;
|
|
tokens.readOnly = (await createUser('readonly', ['tasks:read'])).token;
|
|
tokens.noUpdate = (
|
|
await createUser('noupdate', ['tasks:read', 'tasks:create'])
|
|
).token;
|
|
tokens.noTasks = (await createUser('notasks', ['colonies:read'])).token;
|
|
inactiveUserId = (await createUser('inactive', ['tasks:read'], false)).id;
|
|
});
|
|
|
|
const cleanDb = async () => {
|
|
const ids = (
|
|
await prisma.suggestion.findMany({
|
|
where: { colonyId },
|
|
select: { id: true },
|
|
})
|
|
).map((row) => row.id);
|
|
const taskIds = (
|
|
await prisma.task.findMany({
|
|
where: { sourceSuggestionId: { in: ids } },
|
|
select: { id: true },
|
|
})
|
|
).map((row) => row.id);
|
|
await prisma.domainEvent.deleteMany({
|
|
where: { aggregateId: { in: taskIds } },
|
|
});
|
|
await prisma.calendarSyncOutboxEntry.deleteMany({
|
|
where: { sourceType: 'TASK', sourceId: { in: taskIds } },
|
|
});
|
|
await prisma.task.deleteMany({ where: { id: { in: taskIds } } });
|
|
await prisma.suggestion.deleteMany({ where: { colonyId } });
|
|
};
|
|
|
|
afterEach(cleanDb);
|
|
|
|
afterAll(async () => {
|
|
await cleanDb();
|
|
await prisma.colony.delete({ where: { id: colonyId } });
|
|
await prisma.apiary.delete({ where: { id: apiaryId } });
|
|
await prisma.idempotencyRecord.deleteMany({
|
|
where: { userId: { in: userIds } },
|
|
});
|
|
await prisma.refreshToken.deleteMany({
|
|
where: { userId: { in: userIds } },
|
|
});
|
|
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
|
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
|
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
|
|
await app.close();
|
|
});
|
|
|
|
it('lists open suggestions with rendered title and reasons', async () => {
|
|
const created = await proposeForTest(app, proposal('a1'));
|
|
|
|
const response = await get(tokens.full, `?colonyId=${colonyId}`).expect(
|
|
200,
|
|
);
|
|
|
|
const [view] = response.body as ViewBody[];
|
|
expect(view).toMatchObject({
|
|
id: created.id,
|
|
title: 'Durchsicht fällig',
|
|
reasons: [],
|
|
colonyCode: `SA-${suffix}`,
|
|
apiaryName: `Sug API ${suffix}`,
|
|
status: 'OPEN',
|
|
dueDate: '2026-05-12',
|
|
});
|
|
const byApiary = await get(tokens.full, `?apiaryId=${apiaryId}`).expect(
|
|
200,
|
|
);
|
|
expect(byApiary.body).toHaveLength(1);
|
|
const other = await get(tokens.full, '?apiaryId=none').expect(200);
|
|
expect(other.body).toEqual([]);
|
|
});
|
|
|
|
it('a user without varroa:read does not get the varroa suggestion in the list', async () => {
|
|
const created = await proposeForTest(app, {
|
|
...proposal('v1'),
|
|
ruleKey: 'VARROA_THRESHOLD',
|
|
intent: 'VARROA_TREAT',
|
|
requiredPermission: 'varroa:read',
|
|
});
|
|
|
|
const hidden = (await get(tokens.noVarroa, `?colonyId=${colonyId}`))
|
|
.body as ViewBody[];
|
|
const seen = (await get(tokens.full, `?colonyId=${colonyId}`))
|
|
.body as ViewBody[];
|
|
expect(hidden.map((view) => view.id)).not.toContain(created.id);
|
|
expect(seen.map((view) => view.id)).toContain(created.id);
|
|
});
|
|
|
|
it('answers 404 to a decision on a suggestion the user may not see', async () => {
|
|
const created = await proposeForTest(app, {
|
|
...proposal('v2'),
|
|
ruleKey: 'VARROA_THRESHOLD',
|
|
intent: 'VARROA_TREAT',
|
|
requiredPermission: 'varroa:read',
|
|
});
|
|
|
|
await post(tokens.noVarroa, `/${created.id}/accept`).expect(404);
|
|
await post(tokens.noVarroa, `/${created.id}/dismiss`).expect(404);
|
|
await post(tokens.noVarroa, '/bulk-accept', { ids: [created.id] }).expect(
|
|
404,
|
|
);
|
|
await post(tokens.noVarroa, '/bulk-dismiss', { ids: [created.id] }).expect(
|
|
404,
|
|
);
|
|
await post(tokens.full, `/${created.id}/dismiss`).expect(201);
|
|
await post(tokens.noVarroa, `/${created.id}/reopen`).expect(404);
|
|
expect(await tasksOf(created.id)).toHaveLength(0);
|
|
});
|
|
|
|
it('accept creates exactly one task with sourceSuggestionId and a calendar delivery', async () => {
|
|
const created = await proposeForTest(app, proposal('c1'));
|
|
|
|
const response = await post(tokens.full, `/${created.id}/accept`).expect(
|
|
201,
|
|
);
|
|
|
|
const tasks = await tasksOf(created.id);
|
|
expect(tasks).toHaveLength(1);
|
|
expect(tasks[0]).toMatchObject({
|
|
sourceSuggestionId: created.id,
|
|
title: 'Durchsicht fällig',
|
|
});
|
|
const body = response.body as {
|
|
suggestion: object & { status: string };
|
|
tasks: { id: string }[];
|
|
};
|
|
expect(Object.keys(body).sort()).toEqual(['suggestion', 'tasks']);
|
|
expectViewShape(body.suggestion);
|
|
expect(body.suggestion.status).toBe('ACCEPTED');
|
|
expect(body.tasks.map((task) => task.id)).toEqual([tasks[0].id]);
|
|
expect(body.tasks[0]).toMatchObject({
|
|
sourceSuggestionId: created.id,
|
|
title: 'Durchsicht fällig',
|
|
});
|
|
await drainDomainEvents(app, [tasks[0].id]);
|
|
const events = await prisma.domainEvent.findMany({
|
|
where: { aggregateType: 'Task', aggregateId: tasks[0].id },
|
|
include: { deliveries: true },
|
|
});
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0].deliveries).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
handler: 'calendar-sync.task',
|
|
status: 'DONE',
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('a second accept with another key answers 409 and leaves exactly one task', async () => {
|
|
const created = await proposeForTest(app, proposal('c2'));
|
|
await post(tokens.full, `/${created.id}/accept`).expect(201);
|
|
|
|
const second = await post(tokens.full, `/${created.id}/accept`).expect(409);
|
|
|
|
expect(second.body).toMatchObject({
|
|
message: 'Der Vorschlag ist nicht mehr offen.',
|
|
status: 'ACCEPTED',
|
|
});
|
|
expect(await tasksOf(created.id)).toHaveLength(1);
|
|
});
|
|
|
|
it('the same Idempotency-Key replays the first accept', async () => {
|
|
const created = await proposeForTest(app, proposal('c3'));
|
|
const key = nextKey();
|
|
|
|
const first = await post(
|
|
tokens.full,
|
|
`/${created.id}/accept`,
|
|
{},
|
|
key,
|
|
).expect(201);
|
|
const replay = await post(
|
|
tokens.full,
|
|
`/${created.id}/accept`,
|
|
{},
|
|
key,
|
|
).expect(201);
|
|
|
|
expect(replay.body).toEqual(first.body);
|
|
expect(await tasksOf(created.id)).toHaveLength(1);
|
|
});
|
|
|
|
it('bulk-accept with one non-OPEN id answers 409 and creates no task', async () => {
|
|
const open = await proposeForTest(app, proposal('b1'));
|
|
const dismissed = await proposeForTest(app, {
|
|
...proposal('b2'),
|
|
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
|
intent: 'QUEEN_ACCEPTANCE',
|
|
});
|
|
await post(tokens.full, `/${dismissed.id}/dismiss`).expect(201);
|
|
|
|
await post(tokens.full, '/bulk-accept', {
|
|
ids: [open.id, dismissed.id],
|
|
}).expect(409);
|
|
|
|
expect(await tasksOf(open.id)).toHaveLength(0);
|
|
expect(
|
|
(await prisma.suggestion.findUniqueOrThrow({ where: { id: open.id } }))
|
|
.status,
|
|
).toBe('OPEN');
|
|
});
|
|
|
|
it('bulk endpoints reject 0 or 51 ids', async () => {
|
|
const ids51 = Array.from({ length: 51 }, (_, index) => `id-${index}`);
|
|
|
|
for (const path of ['/bulk-accept', '/bulk-dismiss']) {
|
|
await post(tokens.full, path, { ids: [] }).expect(400);
|
|
await post(tokens.full, path, { ids: ids51 }).expect(400);
|
|
}
|
|
});
|
|
|
|
it('bulk-dismiss dismisses all given suggestions', async () => {
|
|
const first = await proposeForTest(app, proposal('d1'));
|
|
const second = await proposeForTest(app, {
|
|
...proposal('d2'),
|
|
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
|
intent: 'QUEEN_ACCEPTANCE',
|
|
});
|
|
|
|
const response = await post(tokens.full, '/bulk-dismiss', {
|
|
ids: [first.id, second.id],
|
|
}).expect(201);
|
|
|
|
expect(response.body).toEqual({ dismissed: 2 });
|
|
});
|
|
|
|
it('accept with an unknown or inactive assignedUserId answers 400 and creates no task', async () => {
|
|
const created = await proposeForTest(app, proposal('u1'));
|
|
|
|
await post(tokens.full, `/${created.id}/accept`, {
|
|
assignedUserId: 'does-not-exist',
|
|
}).expect(400);
|
|
const inactive = await post(tokens.full, `/${created.id}/accept`, {
|
|
assignedUserId: inactiveUserId,
|
|
}).expect(400);
|
|
|
|
expect(inactive.body).toMatchObject({
|
|
issues: [{ field: 'assignedUserId' }],
|
|
});
|
|
expect(await tasksOf(created.id)).toHaveLength(0);
|
|
});
|
|
|
|
it('dismiss then reopen restores OPEN; the dismissed one is listed for 30 days', async () => {
|
|
const created = await proposeForTest(app, proposal('r1'));
|
|
|
|
const dismissResponse = await post(tokens.full, `/${created.id}/dismiss`, {
|
|
note: 'später',
|
|
}).expect(201);
|
|
expectViewShape(dismissResponse.body as object);
|
|
expect(dismissResponse.body).toMatchObject({
|
|
status: 'DISMISSED',
|
|
decisionNote: 'später',
|
|
});
|
|
const dismissed = await get(
|
|
tokens.full,
|
|
`?status=DISMISSED&colonyId=${colonyId}`,
|
|
).expect(200);
|
|
expect(await get(tokens.full, `?colonyId=${colonyId}`)).toMatchObject({
|
|
body: [],
|
|
});
|
|
await prisma.$executeRaw`UPDATE "Suggestion" SET "decidedAt" = now() - interval '31 days' WHERE id = ${created.id}`;
|
|
const stale = await get(
|
|
tokens.full,
|
|
`?status=DISMISSED&colonyId=${colonyId}`,
|
|
).expect(200);
|
|
await prisma.$executeRaw`UPDATE "Suggestion" SET "decidedAt" = now() WHERE id = ${created.id}`;
|
|
const reopened = await post(tokens.full, `/${created.id}/reopen`).expect(
|
|
201,
|
|
);
|
|
|
|
expect(dismissed.body).toHaveLength(1);
|
|
expect((dismissed.body as { decisionNote: string }[])[0]).toMatchObject({
|
|
decisionNote: 'später',
|
|
});
|
|
expect(stale.body).toEqual([]);
|
|
expectViewShape(reopened.body as object);
|
|
expect(reopened.body).toMatchObject({ status: 'OPEN' });
|
|
expect(await get(tokens.full, `?colonyId=${colonyId}`)).toMatchObject({
|
|
body: [expect.objectContaining({ id: created.id })],
|
|
});
|
|
});
|
|
|
|
it('rejects a dismiss note over 500 characters', async () => {
|
|
const created = await proposeForTest(app, proposal('n1'));
|
|
|
|
await post(tokens.full, `/${created.id}/dismiss`, {
|
|
note: 'x'.repeat(501),
|
|
}).expect(400);
|
|
});
|
|
|
|
it('403 without tasks:create on accept and without tasks:update on dismiss', async () => {
|
|
const created = await proposeForTest(app, proposal('p1'));
|
|
|
|
await post(tokens.readOnly, `/${created.id}/accept`).expect(403);
|
|
await post(tokens.readOnly, '/bulk-accept', { ids: [created.id] }).expect(
|
|
403,
|
|
);
|
|
await post(tokens.noUpdate, `/${created.id}/dismiss`).expect(403);
|
|
await post(tokens.noUpdate, `/${created.id}/reopen`).expect(403);
|
|
await post(tokens.noUpdate, '/bulk-dismiss', { ids: [created.id] }).expect(
|
|
403,
|
|
);
|
|
await request(app.getHttpServer()).get(base).expect(401);
|
|
});
|
|
|
|
it('bulk-accept answers one result per suggestion in the view shape', async () => {
|
|
const first = await proposeForTest(app, proposal('ba1'));
|
|
const second = await proposeForTest(app, {
|
|
...proposal('ba2'),
|
|
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
|
intent: 'QUEEN_ACCEPTANCE',
|
|
});
|
|
|
|
const response = await post(tokens.full, '/bulk-accept', {
|
|
ids: [first.id, second.id],
|
|
}).expect(201);
|
|
|
|
const results = response.body as {
|
|
suggestion: { id: string };
|
|
tasks: object[];
|
|
}[];
|
|
expect(results.map((result) => result.suggestion.id)).toEqual([
|
|
first.id,
|
|
second.id,
|
|
]);
|
|
for (const result of results) {
|
|
expectViewShape(result.suggestion);
|
|
expect(result.tasks).toHaveLength(1);
|
|
}
|
|
});
|
|
|
|
it('403 on the list and the summary without tasks:read', async () => {
|
|
await get(tokens.noTasks, '').expect(403);
|
|
await get(tokens.noTasks, '/summary').expect(403);
|
|
});
|
|
|
|
it('rejects an invalid status, a datetime dueDate and a bad limit with 400', async () => {
|
|
const created = await proposeForTest(app, proposal('v400'));
|
|
|
|
await get(tokens.full, '?status=NOPE').expect(400);
|
|
await get(tokens.full, '?limit=501').expect(400);
|
|
await get(tokens.full, '?limit=0').expect(400);
|
|
await get(tokens.full, '?offset=-1').expect(400);
|
|
const response = await post(tokens.full, `/${created.id}/accept`, {
|
|
dueDate: '2026-05-12T10:00:00Z',
|
|
}).expect(400);
|
|
|
|
expect(JSON.stringify(response.body)).toContain('JJJJ-MM-TT');
|
|
expect(await tasksOf(created.id)).toHaveLength(0);
|
|
});
|
|
|
|
it('limit and offset page through the list', async () => {
|
|
await proposeForTest(app, proposal('l1'));
|
|
await proposeForTest(app, {
|
|
...proposal('l2'),
|
|
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
|
intent: 'QUEEN_ACCEPTANCE',
|
|
});
|
|
const all = (await get(tokens.full, `?colonyId=${colonyId}`).expect(200))
|
|
.body as ViewBody[];
|
|
|
|
const first = (
|
|
await get(tokens.full, `?colonyId=${colonyId}&limit=1`).expect(200)
|
|
).body as ViewBody[];
|
|
const second = (
|
|
await get(tokens.full, `?colonyId=${colonyId}&limit=1&offset=1`).expect(
|
|
200,
|
|
)
|
|
).body as ViewBody[];
|
|
|
|
expect(all).toHaveLength(2);
|
|
expect(first.map((view) => view.id)).toEqual([all[0].id]);
|
|
expect(second.map((view) => view.id)).toEqual([all[1].id]);
|
|
});
|
|
|
|
it('combines the apiaryId filter with status and colonyId', async () => {
|
|
const created = await proposeForTest(app, proposal('f1'));
|
|
await post(tokens.full, `/${created.id}/dismiss`).expect(201);
|
|
|
|
const hit = await get(
|
|
tokens.full,
|
|
`?apiaryId=${apiaryId}&colonyId=${colonyId}&status=DISMISSED`,
|
|
).expect(200);
|
|
const wrongStatus = await get(
|
|
tokens.full,
|
|
`?apiaryId=${apiaryId}&colonyId=${colonyId}&status=OPEN`,
|
|
).expect(200);
|
|
const wrongApiary = await get(
|
|
tokens.full,
|
|
`?apiaryId=none&colonyId=${colonyId}&status=DISMISSED`,
|
|
).expect(200);
|
|
|
|
expect((hit.body as ViewBody[]).map((view) => view.id)).toEqual([
|
|
created.id,
|
|
]);
|
|
expect(wrongStatus.body).toEqual([]);
|
|
expect(wrongApiary.body).toEqual([]);
|
|
});
|
|
|
|
it('sorts by due date (none last), then priority (highest first)', async () => {
|
|
const mk = (
|
|
anchor: string,
|
|
intent: ProposeInput['intent'],
|
|
priority: ProposeInput['priority'],
|
|
dueDate: string | null,
|
|
) =>
|
|
proposeForTest(app, { ...proposal(anchor), intent, priority, dueDate });
|
|
const undated = await mk('s1', 'QUEEN_CHECK', 'URGENT', null);
|
|
const normal = await mk('s2', 'INSPECT', 'NORMAL', '2026-05-12');
|
|
const urgent = await mk('s3', 'SWARM_CHECK', 'URGENT', '2026-05-12');
|
|
const early = await mk('s4', 'HEALTH_CHECK', 'LOW', '2026-05-10');
|
|
|
|
const response = await get(tokens.full, `?colonyId=${colonyId}`).expect(
|
|
200,
|
|
);
|
|
|
|
expect((response.body as ViewBody[]).map((view) => view.id)).toEqual([
|
|
early.id,
|
|
urgent.id,
|
|
normal.id,
|
|
undated.id,
|
|
]);
|
|
});
|
|
|
|
it('shows the cleanup payload from the openTaskIds evidence', async () => {
|
|
await proposeForTest(app, {
|
|
...proposal('cl1'),
|
|
ruleKey: 'COLONY_ENDED_CLEANUP',
|
|
intent: 'CLEANUP',
|
|
requiredPermission: 'colonies:read',
|
|
evidence: {
|
|
colonyId,
|
|
status: 'DECEASED',
|
|
endedOn: '2026-03-12',
|
|
via: 'winter',
|
|
openTaskIds: ['t_a', 't_b'],
|
|
queenId: 'q_1',
|
|
},
|
|
});
|
|
await proposeForTest(app, { ...proposal('cl2'), intent: 'QUEEN_CHECK' });
|
|
|
|
const views = (await get(tokens.full, `?colonyId=${colonyId}`).expect(200))
|
|
.body as { intent: string; cleanup: unknown }[];
|
|
|
|
expect(views.find((v) => v.intent === 'CLEANUP')?.cleanup).toEqual({
|
|
taskIds: ['t_a', 't_b'],
|
|
queenId: 'q_1',
|
|
});
|
|
expect(views.find((v) => v.intent === 'QUEEN_CHECK')?.cleanup).toBeNull();
|
|
});
|
|
|
|
it('bulk-dismiss answers { dismissed } only', async () => {
|
|
const created = await proposeForTest(app, proposal('bd1'));
|
|
|
|
const response = await post(tokens.full, '/bulk-dismiss', {
|
|
ids: [created.id],
|
|
}).expect(201);
|
|
|
|
expect(response.body).toEqual({ dismissed: 1 });
|
|
});
|
|
});
|