Files
PaperClipAI/tests/e2e/board-attachment-receipts.spec.ts
T
DottaandPaperclip 7afee8f062 fix(server): schedule direct GitHub webhook recovery from saved deadlines (#17666)
<!-- entire-trail-link-start -->
https://entire.com/gh/paperclipai/-/trails/218/changes/paperclip/218
<!-- entire-trail-link-end -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - GitHub callbacks can fail before the app records an inbox row.
> - Direct GitHub integrations need bounded recovery from provider
delivery history.
> - The chat timer checks for due recovery every second, even without an
eligible integration.
> - Existing recovery checkpoints already store the next scan time and
runtime epoch.
> - This pull request schedules those saved obligations and wakes on
committed configuration changes.
> - It removes the final chat maintenance interval while preserving
authenticated recovery.

## Linked Issues or Issue Description

**What happened?**

The app queries due GitHub recovery endpoints every second. This still
runs when no direct GitHub integration needs recovery. Other chat
maintenance duties already have independent workers in the preceding
stack.

**What did you expect?**

Reconstruct existing recovery deadlines at startup and after committed
changes. Run only when an eligible endpoint reaches its saved deadline.
Make no recurring SQL or HTTP calls for unconfigured, disabled, or
Cloud-relayed integrations.

**Steps to reproduce**

Start an instance without an active direct GitHub integration. Leave it
idle. The chat maintenance interval continues to call
processFailedGitHubWebhookDeliveries each second.

Follows merged #17664. Related #15981 owns the original GitHub webhook
ingress/recovery separation. #17662 preserves review-check recovery,
which is a separate obligation. No duplicate scheduling migration was
found in the related-PR search.

## What Changed

- Put deadline reconstruction, scopes, and registration in the GitHub
provider owner. Read existing recovery checkpoints in pages of 100.
Reload current eligibility before each dispatch.
- Dispatch batches of five through the existing shared helper. Preserve
company fairness and the default limit of 25 for other consumers.
- Notify inside configuration, credential, checkpoint,
installation-lifecycle, and Cloud-registration transactions. A signed
installation deletion reproduced a missing wake before the fix.
- Detect credential-only epoch changes during a future backoff. Preserve
the existing floor reset without adopting old events into new
credentials.
- Gate every new recovery HTTP call on dispatch admission and shutdown.
Join in-flight work and retain its existing outcome/uncertainty
settlement. Other queues remain independent.
- Remove the final chat reconciliation interval and its unused
coordinator. Update the old coordinator fixtures; preserve the voice
retention worker acceptance.
- Restore known-unsent redelivery claims after drain/shutdown, guarded
by exact attempt identity. Preserve uncertain outcomes after transport
starts and never overwrite a newer receipt.
- Keep all existing redelivery, source, authority, lease, receipt, and
callback checks. Add no table, migration, or external wake contract.

## Verification

Current source: `ab00fc5b30d51d6c85891536599e7bdfd0c822fe`.

- **751 distinct cases pass** at
`3d7f163bc5259b60f9bd19b7fc90b402f0a6e925`: 274 complete GitHub review
cases, 294 other GitHub-named cases, 30 publication cases, 145
voice/retention cases, and eight startup cases.
- All 19 new recovery worker cases and all six stop-before-transport
variants pass. Coverage includes configured eligibility, callback-only
admission, committed configuration changes, outer transaction
settlement, restart/drain/standby, saved deadlines, fair batches,
independent queues, held HTTP, joined shutdown, known-unsent
restoration, and newer-receipt fencing.
- The first workflow filter omitted the separate recovery group. The
saved case list exposed the gap. The second selection ran the remaining
GitHub cases in a new database. Passed-name sets are disjoint. The two
commands filtered 1,504 and 1,484 unrelated cases respectively. No full
chat or repository-suite claim is made.
- Database build, server typecheck/build, module boundaries, and
migration order pass. Five fresh PostgreSQL fixtures receive the
unchanged parent migration history through 0403. This slice adds no
database migration or table.
- The parent browser-fixture repair is carried with both complete
patches preserved. At predecessor
`836acef0370d42323fdfdbd6d62a805b2469f1d4`, entire server, package and
UI source trees and owned hashes match the 751-case acceptance. The
current follow-up adds three test cases and audit evidence; production
source remains unchanged. The parent has merged.
- [The owning
audit](https://github.com/paperclipai/paperclip/blob/ab00fc5b30d51d6c85891536599e7bdfd0c822fe/doc/plans/2026-10-25-github-webhook-recovery-sleep-audit.md)
preserves earlier failures and exact acceptance scope. Fresh exact-head
Greptile and all CI gates remain required.

## Risks

- Direct GitHub endpoints still require the existing one-minute external
history cadence, with five-minute failure/claim backoff and provider
backoff up to one day. Cloud relay endpoints are excluded. This does not
promise network-idle direct integrations.
- The existing history, envelope, attempt, and lease limits remain.
Provider history never substitutes for a genuine authenticated callback.
- Supported producers share the current database owner's process-local
signals. Independent SQL writers and other server processes need their
own integration. Unknown transaction outcomes remain held by the
existing reconciliation protocol.
- Already-started HTTP keeps its existing timeout and is joined during
shutdown. No later request starts after drain or stop; durable attempt
uncertainty is preserved.
- Host-sleep eligibility and Cloud alarm coverage stay unchanged. Local
worker tests do not prove host/database suspension or fleet savings.
- Each remaining sleep slice needs its own current review and CI checks
before merge.

## Model Used

OpenAI Codex (GPT-6), with reasoning and code execution. The exact
serving model ID and context-window size were not exposed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge


Parent startup-test correction: #17664 fixes its legacy coordinator
boundary marker. This slice removes that coordinator and timer entirely,
so its existing absence assertions supersede that obsolete inspection.
All other parent and upstream changes are preserved. At predecessor
`836acef0370d42323fdfdbd6d62a805b2469f1d4`, complete server, package and
UI trees match the 751-case acceptance; all eight startup cases also
pass after the explicit conflict resolution. Fresh review and CI are
required for this new head.


## Required review finding: orphan assignment investigation

The reported orphan endpoint cannot be committed through the existing
schema: assigned_agent_id is NOT NULL, its agent FK is ON DELETE
RESTRICT, and a validated nondeferrable composite FK enforces the same
company. A first reproduction failed at fixture mutation with PostgreSQL
23502 before reaching the worker; that failure remains recorded. Three
new real-PostgreSQL cases verify NULL/missing/deleted assignments are
rejected, then prove the preserved valid endpoint recovers once, saves
its next deadline, and makes no SQL reads during a 15-second
pre-deadline interval. All 22 recovery-scheduling cases pass (19
previous plus three new); unselected cases are not counted. The
preceding style-only commit preserved production behavior and
non-comment tokens, and the prior 751-case evidence retains its original
scope (754 distinct cases across both selections). A new review must
assess this evidence before merge; the old conditional verdict is not
waived. Actual parent squash ancestry is carried with whole-tree
identity before this test-only follow-up.


Repository style follow-up removes seven new code-comment lines from the
recovery ingress owner and constraint tests. Their explanations remain
in the audit. Both files retain identical non-comment TypeScript token
streams, preserving all guards, assertions and deadlines. Production
eligibility SQL is unchanged. Fresh current-head CI and reviews remain
required.


## CI test corrections

The original strict deletion assertion reproduced CI's PostgreSQL-18
failure locally: the forbidden delete reports 23001 (restrict
violation), versus 23503 on PostgreSQL 17. The corrected assertion
accepts only these integrity codes for deletion, retains exact codes for
NULL/missing assignments, and verifies the original agent and assignment
remain. All 22 recovery cases pass on embedded PostgreSQL 18; the same
three integrity probes pass on PostgreSQL 17.

A delayed real announcement response reproduced the attachment E2E's
filechooser timeout: the dismiss handler tried to click outside an
already-open modal menu. It now closes the menu, dismisses through the
UI, and restores that menu so the original action resumes. Controlled
late-announcement acceptance passes; the temporary fault is removed. All
11 complete attachment receipt journeys pass, retries zero and original
assertions/budgets unchanged, using a fresh supported external
PostgreSQL-17 database with canonical isolated
onboarding/server/UI/storage. An earlier embedded-cluster startup
failure occurred before tests and remains recorded. Production server
typecheck passes; production source and schema are unchanged. Fresh
current-head CI and review are required.

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-28 20:05:31 -05:00

475 lines
17 KiB
TypeScript

import { randomUUID } from "node:crypto";
import {
expect,
test,
type APIRequestContext,
type Locator,
type Page,
} from "@playwright/test";
// Real local Board UI, upload storage, comment HTTP routes, and disposable DB.
// Only the interface flag and explicit transport/upload rejection faults are mocked.
// Tasks have no recipient: no agent, runner, or provider is contacted.
type Attachment = {
id: string;
issueCommentId: string | null;
contentPath: string;
};
type Comment = { id: string; body: string; clientRequestId?: string | null };
async function body<T>(
response: Awaited<ReturnType<APIRequestContext["get"]>>,
): Promise<T> {
expect(response.ok(), `${response.status()}: ${await response.text()}`).toBe(
true,
);
return response.json() as Promise<T>;
}
async function setup(page: Page, request: APIRequestContext, classic: boolean) {
// Announcements can appear after navigation or reload and cover the classic
// composer's attachment button. Dismiss through the same UI as a Board user.
await page.addLocatorHandler(
page.getByRole("complementary", { name: "Paperclip announcements", exact: true })
.getByRole("button", { name: "Dismiss announcement", exact: true }),
async (dismiss) => {
const menuOpen = await page.getByRole("menu", { name: "Add to composer", exact: true }).isVisible();
if (menuOpen) await page.keyboard.press("Escape");
await dismiss.click();
if (menuOpen) await page.getByRole("button", { name: "Add to composer", exact: true }).click();
},
);
const company = await body<{ id: string; issuePrefix: string }>(
await request.post("/api/companies", {
data: { name: `Board receipt browser ${randomUUID()}` },
}),
);
const issue = await body<{ id: string; identifier: string }>(
await request.post(`/api/companies/${company.id}/issues`, {
data: {
title: "Inspect the newly uploaded Board files",
status: "todo",
// Keep attachment sends on the comment route. A task assigned to the
// current human now suggests an empty recipient and sends a reassignment.
assigneeUserId: null,
},
}),
);
await page.route("**/api/instance/settings/experimental", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ enableClassicTaskInterface: classic }),
}),
);
await page.goto(`/${company.issuePrefix}/issues/${issue.identifier}`);
await expect(
page.getByRole("heading", {
name: "Inspect the newly uploaded Board files",
exact: true,
}),
).toBeVisible();
const composer = classic
? page.getByTestId("issue-chat-composer")
: page.locator(".paperclip-task-chat-composer");
const editor = composer.getByRole("textbox", {
name: "editable markdown",
exact: true,
});
const send = classic
? composer.getByRole("button", { name: /Send|Reply/ }).last()
: page.getByTestId("task-chat-composer-send");
await expect(editor).toBeVisible();
return {
company,
issue,
composer,
editor,
send,
sendComment: async () => {
await send.click();
if (classic) {
const confirm = page.getByTestId("issue-chat-no-assignee-send-anyway");
await expect(confirm).toBeVisible();
await confirm.click();
}
},
attachments: () =>
request
.get(`/api/issues/${issue.id}/attachments`)
.then(body<Attachment[]>),
comments: () =>
request.get(`/api/issues/${issue.id}/comments`).then(body<Comment[]>),
};
}
const files = [
{
name: "board-fresh.txt",
mimeType: "text/plain",
buffer: Buffer.from("Object: cat\nAccent: teal\nCount: 3\n"),
},
{
name: "board-fresh.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jfFoAAAAASUVORK5CYII=",
"base64",
),
},
];
async function openAttachmentChooser(page: Page, composer: Locator) {
await composer.getByRole("button", { name: "Add to composer" }).click();
const chooser = page.waitForEvent("filechooser");
await page.getByRole("menuitem", { name: "Files and images", exact: true }).click();
return chooser;
}
async function upload(
page: Page,
fixture: Awaited<ReturnType<typeof setup>>,
file: (typeof files)[number],
) {
const chooser = await openAttachmentChooser(page, fixture.composer);
const response = page.waitForResponse(
(res) =>
res.request().method() === "POST" &&
new URL(res.url()).pathname.endsWith(
`/issues/${fixture.issue.id}/attachments`,
),
);
await chooser.setFiles(file);
const receipt = await body<Attachment>(await response);
await expect
.poll(async () =>
(await fixture.attachments()).some((item) => item.id === receipt.id),
)
.toBe(true);
return receipt;
}
for (const classic of [false, true]) {
test(`uploaded file and image receipts survive reload and bind exactly once (classic=${classic})`, async ({
page,
request,
}, testInfo) => {
const fixture = await setup(page, request, classic);
await fixture.editor.fill(
"Inspect only these fresh Board attachments. Keep this internal.",
);
const receipts = [];
for (const file of files) receipts.push(await upload(page, fixture, file));
const draftKey = `paperclip:issue-comment-draft:${fixture.issue.id}`;
await expect
.poll(() =>
page.evaluate(
(key) =>
JSON.parse(localStorage.getItem(`${key}:attachments:v1`) ?? "null")
?.attachments?.length,
draftKey,
),
)
.toBe(2);
await expect
.poll(() => page.evaluate((key) => localStorage.getItem(key), draftKey))
.toContain("Inspect only these fresh Board attachments.");
await testInfo.attach("before-reload-draft", {
body: JSON.stringify(
await page.evaluate(
(key) => ({
text: localStorage.getItem(key),
receipts: localStorage.getItem(`${key}:attachments:v1`),
}),
draftKey,
),
),
contentType: "application/json",
});
await page.reload();
await expect(fixture.editor).toContainText(
"Inspect only these fresh Board attachments.",
);
await expect(
fixture.composer.getByText("board-fresh.txt", { exact: true }),
).toBeVisible();
await expect(fixture.composer.locator("img")).toHaveCount(1);
const outbound = page.waitForRequest(
(req) =>
req.method() === "POST" &&
new URL(req.url()).pathname.endsWith("/comments"),
);
await fixture.sendComment();
expect((await outbound).postDataJSON().attachmentIds.sort()).toEqual(
receipts.map((row) => row.id).sort(),
);
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
const [comment] = await fixture.comments();
const savedBubble = classic
? page.locator(`[id="comment-${comment!.id}"]`)
: page.getByTestId("task-chat-human-bubble").filter({
hasText:
"Inspect only these fresh Board attachments. Keep this internal.",
});
await expect(savedBubble).toHaveCount(1);
expect(comment.body).toContain(receipts[0]!.contentPath);
expect(comment.body).toContain(receipts[1]!.contentPath);
expect(
(await fixture.attachments()).map((item) => item.issueCommentId),
).toEqual([comment.id, comment.id]);
for (let index = 0; index < receipts.length; index++) {
const downloaded = await request.get(receipts[index]!.contentPath);
expect(downloaded.ok()).toBe(true);
expect(await downloaded.body()).toEqual(files[index]!.buffer);
}
await expect
.poll(() =>
page.evaluate(
(key) => localStorage.getItem(`${key}:attachments:v1`),
draftKey,
),
)
.toBeNull();
await page.reload();
await expect(fixture.editor).toBeEmpty();
expect(await fixture.comments()).toHaveLength(1);
await expect(savedBubble).toHaveCount(1);
await page.screenshot({
path: testInfo.outputPath("bound-board-attachments.png"),
fullPage: true,
});
});
test(`lost accepted response settles from its exact receipt without replay (classic=${classic})`, async ({
page,
request,
}, testInfo) => {
const fixture = await setup(page, request, classic);
await fixture.editor.fill("Accepted once: inspect this exact file.");
const receipt = await upload(page, fixture, files[0]!);
let accepted = false;
let attempts = 0;
let acceptedRequestId: string | undefined;
await page.route("**/api/issues/*/comments", async (route) => {
if (route.request().method() !== "POST") return route.continue();
attempts++;
if (accepted) return route.continue();
acceptedRequestId = route.request().postDataJSON().clientRequestId;
const response = await route.fetch();
expect(response.status()).toBe(201);
accepted = true;
await route.abort("connectionreset");
});
await fixture.sendComment();
await expect.poll(() => accepted).toBe(true);
expect(await fixture.comments()).toHaveLength(1);
expect(acceptedRequestId).toEqual(expect.any(String));
expect((await fixture.comments())[0]!.clientRequestId).toBe(acceptedRequestId);
expect(
(await fixture.attachments()).find((row) => row.id === receipt.id)
?.issueCommentId,
).toBeTruthy();
await page.reload();
// The durable request receipt proves delivery even though the POST reply
// was lost. No manual Review/Discard bookkeeping or blind replay remains.
await expect(fixture.editor).toBeEmpty();
await expect(fixture.composer.getByRole("alert")).toHaveCount(0);
await expect(fixture.composer.getByText("board-fresh.txt", { exact: true })).toHaveCount(0);
await expect(fixture.send).toBeDisabled();
await fixture.editor.press("Control+Enter");
expect(attempts).toBe(1);
expect(await fixture.comments()).toHaveLength(1);
await page.screenshot({
path: testInfo.outputPath("accepted-response-lost.png"),
fullPage: true,
});
expect(
(await fixture.attachments()).find((row) => row.id === receipt.id)
?.issueCommentId,
).toBeTruthy();
await page.reload();
await expect(fixture.composer.getByRole("alert")).toHaveCount(0);
await fixture.editor.fill(
"A deliberately new comment after the original receipt settled.",
);
await fixture.sendComment();
await expect.poll(async () => (await fixture.comments()).length).toBe(2);
});
test(`known rejection retains the same uploaded receipt for explicit retry (classic=${classic})`, async ({
page,
request,
}) => {
const fixture = await setup(page, request, classic);
await fixture.editor.fill("Known rejection, then retry the same file.");
const receipt = await upload(page, fixture, files[0]!);
let rejected = false;
await page.route("**/api/issues/*/comments", async (route) => {
if (route.request().method() !== "POST" || rejected)
return route.continue();
rejected = true;
await route.fulfill({
status: 422,
contentType: "application/json",
body: JSON.stringify({ error: "Fixture policy rejected this attempt" }),
});
});
await fixture.sendComment();
await expect(fixture.editor).toContainText("Known rejection,");
await expect(fixture.send).toBeEnabled();
expect(await fixture.comments()).toHaveLength(0);
await page.reload();
await expect(fixture.editor).toContainText("Known rejection,");
await expect(fixture.composer.getByRole("alert")).toHaveCount(0);
await fixture.sendComment();
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
expect(
(await fixture.attachments()).find((row) => row.id === receipt.id)
?.issueCommentId,
).toBeTruthy();
});
test(`reload during a pending save settles its receipt and preserves a newer draft (classic=${classic})`, async ({
page,
request,
}) => {
const fixture = await setup(page, request, classic);
await fixture.editor.fill("One text-only save interrupted by reload.");
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
let accepted = false;
let attempts = 0;
let acceptedRequestId: string | undefined;
await page.route("**/api/issues/*/comments", async (route) => {
if (route.request().method() !== "POST") return route.continue();
attempts++;
acceptedRequestId ??= route.request().postDataJSON().clientRequestId;
const response = await route.fetch();
expect(response.status()).toBe(201);
accepted = true;
await held;
// Reload cancels the original browser request; the owned server response
// has already been observed and must never be replayed by the fixture.
await route.fulfill({ response }).catch(() => {});
});
try {
await fixture.sendComment();
await expect.poll(() => accepted).toBe(true);
expect(await fixture.comments()).toHaveLength(1);
await fixture.editor.fill("A newer draft written while delivery was pending.");
await page.reload();
release();
await expect(fixture.editor).toHaveText(
"A newer draft written while delivery was pending.",
);
await expect(fixture.composer.getByRole("alert")).toHaveCount(0);
await expect(fixture.send).toBeEnabled();
expect(attempts).toBe(1);
expect(await fixture.comments()).toHaveLength(1);
expect(acceptedRequestId).toEqual(expect.any(String));
expect((await fixture.comments())[0]!.clientRequestId).toBe(acceptedRequestId);
await fixture.sendComment();
await expect.poll(async () => (await fixture.comments()).length).toBe(2);
expect(attempts).toBe(2);
expect((await fixture.comments()).map((comment) => comment.body).sort()).toEqual([
"A newer draft written while delivery was pending.",
"One text-only save interrupted by reload.",
]);
} finally {
release();
}
});
}
for (const classic of [false, true])
test(`removed pending inline upload cannot reappear or submit after its HTTP response (classic=${classic})`, async ({
page,
request,
}) => {
const fixture = await setup(page, request, classic);
await fixture.editor.fill("Send without the removed image.");
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
let arrived = false;
await page.route(
`**/api/companies/${fixture.company.id}/issues/${fixture.issue.id}/attachments`,
async (route) => {
const response = await route.fetch();
arrived = true;
await held;
await route.fulfill({ response });
},
);
const chooser = await openAttachmentChooser(page, fixture.composer);
await chooser.setFiles(files[1]!);
await expect.poll(() => arrived).toBe(true);
await expect(fixture.send).toBeDisabled();
try {
await expect(
fixture.composer.getByText(
classic ? "Uploading to task" : "Uploading…",
{ exact: true },
),
).toBeVisible();
const remove = fixture.composer.getByRole("button", {
name: "Remove board-fresh.png",
});
await expect(remove).toBeVisible();
await remove.click();
release();
await expect(fixture.send).toBeEnabled();
await expect(fixture.composer.locator("img")).toHaveCount(0);
const outbound = page.waitForRequest(
(req) =>
req.method() === "POST" &&
new URL(req.url()).pathname.endsWith("/comments"),
);
await fixture.sendComment();
expect((await outbound).postDataJSON().attachmentIds).toBeUndefined();
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
expect((await fixture.comments())[0]!.body).not.toContain(
"/api/attachments/",
);
expect((await fixture.attachments())[0]!.issueCommentId).toBeNull();
} finally {
release();
}
});
test("legacy failed upload can be removed before sending the retained text", async ({
page,
request,
}) => {
const fixture = await setup(page, request, true);
await fixture.editor.fill("Keep this text after removing the failed file.");
await page.route(
`**/api/companies/${fixture.company.id}/issues/${fixture.issue.id}/attachments`,
(route) =>
route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ error: "Fixture upload rejected" }),
}),
);
const chooser = await openAttachmentChooser(page, fixture.composer);
await chooser.setFiles(files[0]!);
await expect(
fixture.composer.getByText("Fixture upload rejected", { exact: true }),
).toBeVisible();
await expect(fixture.send).toBeDisabled();
const remove = fixture.composer.getByRole("button", {
name: "Remove board-fresh.txt",
});
await expect(remove).toBeVisible();
await remove.click();
await fixture.sendComment();
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
expect((await fixture.comments())[0]!.body).toBe(
"Keep this text after removing the failed file.",
);
expect(await fixture.attachments()).toHaveLength(0);
});