feat: attachments on tickets and comments
Files are uploaded over REST, stored on disk (a volume in Docker) and recorded on the ticket as events; the bytes are written first, so no attachment ever points at a missing file. Only images, video, audio, PDF and plain text are shown inline, everything with nosniff and a sandbox CSP. The web shows thumbnails, uploads by drag and drop or by pasting a screenshot, and attaches files to new comments. Also adds `nx run api:contracts`, which writes schema.gql and openapi.json without starting the API or any database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BusinessRuleError,
|
||||
ForbiddenError,
|
||||
ValidationError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { IssueCreatedEvent } from '../events/issue-created.event';
|
||||
@@ -154,4 +155,55 @@ describe('Issue', () => {
|
||||
});
|
||||
expect(rebuilt.getUncommittedEvents()).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('attachments', () => {
|
||||
const file = (id: string, uploadedBy = 'u-qa') => ({
|
||||
id,
|
||||
fileName: 'C:\\Users\\qa\\captura login.png',
|
||||
mimeType: 'image/png',
|
||||
size: 2048,
|
||||
uploadedBy,
|
||||
uploadedAt: '2026-10-01T10:00:00.000Z',
|
||||
commentId: null,
|
||||
});
|
||||
|
||||
it('records a file with its name cleaned up, once', () => {
|
||||
const issue = newIssue();
|
||||
issue.attach('u-qa', file('a-1'));
|
||||
issue.attach('u-qa', file('a-1'));
|
||||
|
||||
expect(issue.attachments).toHaveLength(1);
|
||||
expect(issue.attachments[0].fileName).toBe('captura login.png');
|
||||
});
|
||||
|
||||
it('lets the uploader or a manager remove it, nobody else', () => {
|
||||
const issue = newIssue();
|
||||
issue.attach('u-qa', file('a-1'));
|
||||
|
||||
expect(() => issue.detach('u-dev', 'a-1', false)).toThrow(ForbiddenError);
|
||||
issue.detach('u-dev', 'a-1', true);
|
||||
|
||||
expect(issue.attachments).toEqual([]);
|
||||
expect(() => issue.detach('u-qa', 'a-1', true)).toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects empty files', () => {
|
||||
expect(() =>
|
||||
newIssue().attach('u-qa', { ...file('a-1'), size: 0 }),
|
||||
).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('survives a replay', () => {
|
||||
const issue = newIssue();
|
||||
issue.attach('u-qa', file('a-1'));
|
||||
issue.attach('u-qa', file('a-2'));
|
||||
issue.detach('u-qa', 'a-1', false);
|
||||
|
||||
const { rebuilt } = replay(issue, Issue);
|
||||
|
||||
expect(rebuilt.attachments.map((a) => a.id)).toEqual(['a-2']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user