Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 42 additions & 39 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"dependencies": {
"@internxt/css-config": "^1.1.0",
"@internxt/lib": "^1.4.1",
"@internxt/sdk": "^1.16.3",
"@internxt/sdk": "1.17.2",
"@internxt/ui": "^0.1.16",
"@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.11.2",
Expand All @@ -44,7 +44,7 @@
"dompurify": "^3.3.3",
"i18next": "^25.8.13",
"idb": "^8.0.3",
"internxt-crypto": "^1.3.0",
"internxt-crypto": "1.4.0",
"prettysize": "^2.0.0",
"react": "^19.2.0",
"react-device-detect": "^2.2.3",
Expand Down
204 changes: 204 additions & 0 deletions src/components/compose-message/hooks/useComposeSend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { renderHook, act } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import type { Editor } from '@tiptap/react';
import type { EncryptionBlock } from '@internxt/sdk/dist/mail/types';
import useComposeSend from './useComposeSend';
import { MailEncryptionService } from '@/services/mail-encryption';
import notificationsService from '@/services/notifications';
import type { Recipient } from '../types';
Comment on lines +5 to +8
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Use project path aliases for internal imports in tests.

Switch the relative imports to @/* aliases to match repository import policy.

Proposed patch
-import useComposeSend from './useComposeSend';
+import useComposeSend from '`@/components/compose-message/hooks/useComposeSend`';
 import { MailEncryptionService } from '`@/services/mail-encryption`';
 import notificationsService from '`@/services/notifications`';
-import type { Recipient } from '../types';
+import type { Recipient } from '`@/components/compose-message/types`';

As per coding guidelines, "Use the path alias @/*src/* when importing internal modules."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/compose-message/hooks/useComposeSend.test.ts` around lines 5 -
8, The test imports useComposeSend, MailEncryptionService, notificationsService
and Recipient via relative paths; update those imports to use the project alias
pattern (`@/`*) instead of relative paths so internal modules follow repo
policy—replace the relative import of useComposeSend, the MailEncryptionService
import, notificationsService import, and the Recipient type import with their
equivalent '`@/`...' paths (e.g. start with '`@/`' and mirror the src/* layout) so
the test uses alias-based internal imports.


const mocks = vi.hoisted(() => ({
activeDomains: undefined as { domain: string }[] | undefined,
senderKeys: undefined as { address: string; publicKey: string } | undefined,
triggerLookup: vi.fn(),
sendEmail: vi.fn(),
}));

vi.mock('@/store/api/mail', () => ({
useGetActiveDomainsQuery: () => ({ data: mocks.activeDomains }),
useGetMailAccountKeysQuery: () => ({ data: mocks.senderKeys }),
useLazyLookupRecipientKeysQuery: () => [mocks.triggerLookup],
useSendEmailMutation: () => [mocks.sendEmail, { isLoading: false }],
}));

vi.mock('@/i18n', () => ({ useTranslationContext: () => ({ translate: (key: string) => key }) }));

vi.mock('@/services/notifications', () => ({
default: { show: vi.fn() },
ToastType: { Success: 'success', Error: 'error', Warning: 'warning', Info: 'info', Loading: 'loading' },
}));

const editor = { getHTML: () => '<p>body</p>', getText: () => 'body' } as unknown as Editor;
const recipient = (email: string): Recipient => ({ id: email, email });
const show = vi.mocked(notificationsService.show);

const renderSend = (overrides: Partial<Parameters<typeof useComposeSend>[0]> = {}) => {
const onSent = vi.fn();
const { result } = renderHook(() =>
useComposeSend({
toRecipients: [],
ccRecipients: [],
bccRecipients: [],
subject: 'Hi',
editor,
onSent,
...overrides,
}),
);
return { result, onSent };
};

describe('useComposeSend', () => {
beforeEach(() => {
vi.restoreAllMocks();
mocks.triggerLookup.mockReset();
mocks.sendEmail.mockReset();
show.mockReset();

mocks.activeDomains = [{ domain: 'inxt.me' }];
mocks.senderKeys = { address: 'me@inxt.me', publicKey: 'sender-pk' };
mocks.triggerLookup.mockReturnValue({ unwrap: () => Promise.resolve([]) });
mocks.sendEmail.mockReturnValue({ unwrap: () => Promise.resolve({ id: 'mail-1' }) });
});

test('When there are no recipients, then it warns and does not send', async () => {
const { result, onSent } = renderSend();

await act(async () => {
await result.current.send();
});

expect(show).toHaveBeenCalledWith(expect.objectContaining({ text: 'errors.mail.noRecipients' }));
expect(mocks.sendEmail).not.toHaveBeenCalled();
expect(onSent).not.toHaveBeenCalled();
});

test('When the active domains have not resolved, then the send is blocked to avoid a cleartext downgrade', async () => {
mocks.activeDomains = undefined;

const { result, onSent } = renderSend({ toRecipients: [recipient('bob@inxt.me')] });

expect(result.current.encryptionState).toBe('unknown');

await act(async () => {
await result.current.send();
});

expect(show).toHaveBeenCalledWith(expect.objectContaining({ text: 'errors.mail.encryptionUnavailable' }));
expect(mocks.sendEmail).not.toHaveBeenCalled();
expect(onSent).not.toHaveBeenCalled();
});

test('When sending encrypted but the sender keys are missing, then it reports a key lookup failure', async () => {
mocks.senderKeys = undefined;

const { result, onSent } = renderSend({ toRecipients: [recipient('bob@inxt.me')] });

await act(async () => {
await result.current.send();
});

expect(show).toHaveBeenCalledWith(expect.objectContaining({ text: 'errors.mail.keyLookupFailed' }));
expect(mocks.sendEmail).not.toHaveBeenCalled();
expect(onSent).not.toHaveBeenCalled();
});

test('When the recipient key lookup throws, then it reports a key lookup failure rather than a send failure', async () => {
mocks.triggerLookup.mockReturnValue({ unwrap: () => Promise.reject(new Error('network')) });

const { result, onSent } = renderSend({ toRecipients: [recipient('bob@inxt.me')] });

await act(async () => {
await result.current.send();
});

expect(show).toHaveBeenCalledWith(expect.objectContaining({ text: 'errors.mail.keyLookupFailed' }));
expect(mocks.sendEmail).not.toHaveBeenCalled();
expect(onSent).not.toHaveBeenCalled();
});

test('When some recipients have no key, then it reports a key lookup failure', async () => {
mocks.triggerLookup.mockReturnValue({
unwrap: () => Promise.resolve([{ address: 'bob@inxt.me', publicKey: null }]),
});

const { result, onSent } = renderSend({ toRecipients: [recipient('bob@inxt.me')] });

await act(async () => {
await result.current.send();
});

expect(show).toHaveBeenCalledWith(expect.objectContaining({ text: 'errors.mail.keyLookupFailed' }));
expect(mocks.sendEmail).not.toHaveBeenCalled();
expect(onSent).not.toHaveBeenCalled();
});

test('When the send mutation fails, then it reports a send failure and does not close the dialog', async () => {
mocks.sendEmail.mockReturnValue({ unwrap: () => Promise.reject(new Error('boom')) });

const { result, onSent } = renderSend({ toRecipients: [recipient('bob@gmail.com')] });

expect(result.current.encryptionState).toBe('cleartext');

await act(async () => {
await result.current.send();
});

expect(show).toHaveBeenCalledWith(expect.objectContaining({ text: 'errors.mail.sendFailed' }));
expect(onSent).not.toHaveBeenCalled();
});

test('When all recipients are external, then it sends cleartext and closes the dialog', async () => {
const { result, onSent } = renderSend({ toRecipients: [recipient('bob@gmail.com')] });

expect(result.current.encryptionState).toBe('cleartext');

await act(async () => {
await result.current.send();
});

expect(mocks.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: [{ email: 'bob@gmail.com' }],
subject: 'Hi',
htmlBody: '<p>body</p>',
textBody: 'body',
}),
);
expect(onSent).toHaveBeenCalled();
});

test('When all recipients are Internxt, then it encrypts the body and sends with the sender included', async () => {
mocks.triggerLookup.mockReturnValue({
unwrap: () => Promise.resolve([{ address: 'bob@inxt.me', publicKey: 'bob-pk' }]),
});
const buildSpy = vi
.spyOn(MailEncryptionService.instance, 'buildEncryptionBlock')
.mockResolvedValue({
version: 'v1',
encryptedText: 'ct',
encryptedPreview: 'cp',
wrappedKeys: [],
} as EncryptionBlock);

const { result, onSent } = renderSend({ toRecipients: [recipient('bob@inxt.me')] });

expect(result.current.encryptionState).toBe('encrypted');

await act(async () => {
await result.current.send();
});

expect(buildSpy).toHaveBeenCalledWith(
{ body: '<p>body</p>', previewText: 'body' },
expect.arrayContaining([
{ address: 'bob@inxt.me', publicKey: 'bob-pk' },
{ address: 'me@inxt.me', publicKey: 'sender-pk' },
]),
);
expect(mocks.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({ subject: 'Hi', encryption: expect.objectContaining({ version: 'v1' }) }),
);
expect(onSent).toHaveBeenCalled();
});
});
Loading
Loading