feat: T4 wave 3 - notifications per user, suggestions API, varroa bands, split/queen events
- Schema III: per-user notifications (broadcast fan-out migration, category, link, requiredPermissions filter), notification preferences and settings endpoints, calendar withdrawal switch, occupiedFrames - Suggestions API: list/summary with permission visibility and paging, accept/dismiss/reopen incl. bulk, SuggestionView on all responses - Varroa traffic light by season and method, young colonies, treatment effect window, sample-based percentage; shared withdrawal-window helper on Berlin calendar days; GET /varroa-measurements/thresholds - ColonySplit and QueenAssigned domain events with calendar and webhook handlers - Outbox claims compare against now() rounded to timestamp(3); fixes the flaky e2e claims of rows written with SQL now() Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
d99a697739
commit
f468dc5f75
87 files changed
+6022
-510
No files matched your search
@@ -0,0 +1,648 @@
|
||||
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 sees neither the varroa suggestion nor its count', async () => {
|
||||
await proposeForTest(app, {
|
||||
...proposal('v1'),
|
||||
ruleKey: 'VARROA_THRESHOLD',
|
||||
intent: 'VARROA_TREAT',
|
||||
requiredPermission: 'varroa:read',
|
||||
});
|
||||
|
||||
const hidden = await get(tokens.noVarroa, `?colonyId=${colonyId}`);
|
||||
const hiddenSummary = await get(tokens.noVarroa, '/summary');
|
||||
const seen = await get(tokens.full, `?colonyId=${colonyId}`);
|
||||
const seenSummary = await get(tokens.full, '/summary');
|
||||
|
||||
expect(hidden.body).toEqual([]);
|
||||
expect((hiddenSummary.body as { open: number }).open).toBe(0);
|
||||
expect(seen.body).toHaveLength(1);
|
||||
expect((seenSummary.body as { open: number }).open).toBeGreaterThanOrEqual(
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
(seenSummary.body as { byPriority: Record<string, number> }).byPriority,
|
||||
).toEqual(
|
||||
expect.objectContaining({ LOW: 0, NORMAL: expect.any(Number) as number }),
|
||||
);
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user