().create({
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
+ appErrorCode: getAppErrorCode(error.cause),
},
};
},
@@ -119,7 +121,7 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
return next({
ctx: {
- // infers the `session` as non-nullable
+ // Infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
diff --git a/src/server/mailer.ts b/src/server/mailer.ts
index 15c601cfc..c6a73d2cb 100644
--- a/src/server/mailer.ts
+++ b/src/server/mailer.ts
@@ -2,6 +2,7 @@ import { type User } from 'next-auth';
import nodemailer, { type Transporter } from 'nodemailer';
import { env } from '~/env';
+import { escapeHtml } from '~/lib/utils';
import { sendToDiscord } from './service-notification';
@@ -29,6 +30,9 @@ const getTransporter = () => {
const transport = {
...mailServerConfig,
secure: 465 === mailServerConfig.port,
+ connectionTimeout: 10_000,
+ greetingTimeout: 10_000,
+ socketTimeout: 30_000,
};
transporter = nodemailer.createTransport(transport);
@@ -61,14 +65,14 @@ export async function sendInviteEmail(email: string, name: string) {
if ('development' === env.NODE_ENV) {
console.log('Sending invite email', email, name);
- return;
+ return true;
}
const subject = 'Invitation to SplitPro';
const text = `Hey,\n\nYou have been invited to SplitPro by ${name}. It's a completely open source free alternative to splitwise. You can sign in to SplitPro by clicking the below URL:\n${env.NEXTAUTH_URL}\n\nThanks,\nSplitPro Team`;
- const html = `Hey,
You have been invited to SplitPro by ${name}. It's a completely open source free alternative to splitwise. You can sign in to SplitPro by clicking the below URL:
Sign in to ${host}
Thanks,
SplitPro Team
`;
+ const html = `Hey,
You have been invited to SplitPro by ${escapeHtml(name)}. It's a completely open source free alternative to splitwise. You can sign in to SplitPro by clicking the below URL:
Sign in to ${host}
Thanks,
SplitPro Team
`;
- await sendMail(email, subject, text, html);
+ return await sendMail(email, subject, text, html);
}
export async function sendFeedbackEmail(feedback: string, user: User) {
@@ -110,13 +114,17 @@ async function sendMail(
}
} catch (error) {
console.log('Error sending email', error);
- await sendToDiscord(
- `Error sending email: ${
- error instanceof Error
- ? `error.message: ${error.message}\nerror.stack: ${error.stack}`
- : 'Unknown error'
- }`,
- );
+ await Promise.resolve(
+ sendToDiscord(
+ `Error sending email: ${
+ error instanceof Error
+ ? `error.message: ${error.message}\nerror.stack: ${error.stack}`
+ : 'Unknown error'
+ }`,
+ ),
+ ).catch((notificationError: unknown) => {
+ console.error('Failed to report email error to Discord', notificationError);
+ });
}
return false;
diff --git a/src/server/service-notification.ts b/src/server/service-notification.ts
index 6d78d3907..eff6b005c 100644
--- a/src/server/service-notification.ts
+++ b/src/server/service-notification.ts
@@ -16,6 +16,7 @@ export async function sendToDiscord(message: string) {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content: message }),
+ signal: AbortSignal.timeout(5_000),
});
if (response.ok) {
diff --git a/tests/appError.test.ts b/tests/appError.test.ts
new file mode 100644
index 000000000..7a96fd5a5
--- /dev/null
+++ b/tests/appError.test.ts
@@ -0,0 +1,27 @@
+import { z } from 'zod';
+
+import { AppError, getAppErrorCode } from '~/server/api/appError';
+
+describe('getAppErrorCode', () => {
+ describe('when the cause is an AppError', () => {
+ it('returns its code', () => {
+ expect(getAppErrorCode(new AppError('SOME_CODE', 'failed'))).toBe('SOME_CODE');
+ });
+ });
+
+ describe('when the cause is not an AppError', () => {
+ it('returns null for undefined', () => {
+ expect(getAppErrorCode(undefined)).toBeNull();
+ });
+
+ it('returns null for a plain Error', () => {
+ expect(getAppErrorCode(new Error('boom'))).toBeNull();
+ });
+
+ it('returns null for a ZodError', () => {
+ const result = z.string().safeParse(123);
+ expect(result.success).toBe(false);
+ expect(getAppErrorCode(!result.success ? result.error : undefined)).toBeNull();
+ });
+ });
+});
diff --git a/tests/inviteCooldown.test.ts b/tests/inviteCooldown.test.ts
new file mode 100644
index 000000000..3c324833b
--- /dev/null
+++ b/tests/inviteCooldown.test.ts
@@ -0,0 +1,31 @@
+import { claimInviteCooldown } from '~/lib/inviteCooldown';
+
+describe('claimInviteCooldown', () => {
+ describe('when the same inviter invites the same user within 60 seconds', () => {
+ it('rejects the second request', () => {
+ expect(claimInviteCooldown(2, 1, 10_000)).toBe(true);
+ expect(claimInviteCooldown(2, 1, 69_999)).toBe(false);
+ });
+ });
+
+ describe('when the cooldown has expired', () => {
+ it('allows another invite after 60 seconds', () => {
+ expect(claimInviteCooldown(3, 2, 10_000)).toBe(true);
+ expect(claimInviteCooldown(3, 2, 70_000)).toBe(true);
+ });
+ });
+
+ describe('when different inviters invite the same user', () => {
+ it('tracks each inviter and invitee pair independently', () => {
+ expect(claimInviteCooldown(4, 3, 10_000)).toBe(true);
+ expect(claimInviteCooldown(4, 5, 10_001)).toBe(true);
+ });
+ });
+
+ describe('when the same inviter invites different users', () => {
+ it('tracks each inviter and invitee pair independently', () => {
+ expect(claimInviteCooldown(7, 6, 10_000)).toBe(true);
+ expect(claimInviteCooldown(8, 6, 10_001)).toBe(true);
+ });
+ });
+});
diff --git a/tests/inviteErrors.test.ts b/tests/inviteErrors.test.ts
new file mode 100644
index 000000000..d11659682
--- /dev/null
+++ b/tests/inviteErrors.test.ts
@@ -0,0 +1,56 @@
+import {
+ InviteErrorCode,
+ getInviteErrorToastKey,
+ isInviteEmailSendFailed,
+} from '~/lib/error/invite';
+
+describe('isInviteEmailSendFailed', () => {
+ describe('when given the delivery failure code', () => {
+ it('returns true', () => {
+ expect(isInviteEmailSendFailed(InviteErrorCode.INVITE_EMAIL_SEND_FAILED)).toBe(true);
+ });
+ });
+
+ describe('when given another code', () => {
+ it.each([
+ InviteErrorCode.INVITES_DISABLED,
+ InviteErrorCode.INVITE_RATE_LIMITED,
+ undefined,
+ null,
+ 123,
+ {},
+ 'SOME_UNRELATED_CODE',
+ '',
+ ])('returns false for %p', (value) => {
+ expect(isInviteEmailSendFailed(value)).toBe(false);
+ });
+ });
+});
+
+describe('getInviteErrorToastKey', () => {
+ describe('when the code is INVITE_EMAIL_SEND_FAILED', () => {
+ it('returns the invite-email-failed key', () => {
+ expect(getInviteErrorToastKey(InviteErrorCode.INVITE_EMAIL_SEND_FAILED)).toBe(
+ 'errors.invite_email_failed',
+ );
+ });
+ });
+
+ describe('when the code is any other InviteErrorCode value', () => {
+ it.each([InviteErrorCode.INVITES_DISABLED, InviteErrorCode.INVITE_RATE_LIMITED])(
+ 'returns the generic add-member-failed key for %s',
+ (code) => {
+ expect(getInviteErrorToastKey(code)).toBe('errors.add_member_failed');
+ },
+ );
+ });
+
+ describe('when the code is unknown, null, or undefined', () => {
+ it.each(['SOME_UNRELATED_CODE', null, undefined])(
+ 'returns the generic add-member-failed key for %p',
+ (value) => {
+ expect(getInviteErrorToastKey(value)).toBe('errors.add_member_failed');
+ },
+ );
+ });
+});
diff --git a/tests/mailer.test.ts b/tests/mailer.test.ts
new file mode 100644
index 000000000..17913b0ff
--- /dev/null
+++ b/tests/mailer.test.ts
@@ -0,0 +1,134 @@
+import nodemailer from 'nodemailer';
+
+import { env } from '~/env';
+import { sendToDiscord } from '~/server/service-notification';
+import { sendInviteEmail } from '~/server/mailer';
+
+jest.mock('~/env', () => ({
+ env: {
+ NODE_ENV: 'production',
+ EMAIL_SERVER_HOST: 'smtp.example.com',
+ EMAIL_SERVER_PORT: '587',
+ EMAIL_SERVER_USER: 'user',
+ EMAIL_SERVER_PASSWORD: 'pass',
+ EMAIL_TLS_REJECT_UNAUTHORIZED: true,
+ FROM_EMAIL: 'noreply@example.com',
+ NEXTAUTH_URL: 'https://splitpro.example.com',
+ ENABLE_SENDING_INVITES: true,
+ },
+}));
+
+jest.mock('nodemailer', () => {
+ const sendMail = jest.fn();
+ return {
+ __esModule: true,
+ default: { createTransport: jest.fn(() => ({ sendMail })) },
+ };
+});
+
+jest.mock('~/server/service-notification', () => ({
+ sendToDiscord: jest.fn(),
+}));
+
+const mockCreateTransport = jest.mocked(nodemailer.createTransport);
+const mockSendMail = jest.mocked(
+ mockCreateTransport({} as Parameters[0]).sendMail,
+);
+const mockSendToDiscord = jest.mocked(sendToDiscord);
+
+const mockSentMessageInfo = {
+ messageId: 'test-message-id',
+ envelope: { from: 'noreply@example.com', to: ['friend@example.com'] },
+ accepted: ['friend@example.com'],
+ rejected: [],
+ pending: [],
+ response: '250 OK',
+};
+
+describe('sendInviteEmail', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (env as { NODE_ENV: string }).NODE_ENV = 'production';
+ (env as { ENABLE_SENDING_INVITES: boolean }).ENABLE_SENDING_INVITES = true;
+ });
+
+ it('creates the SMTP transport with bounded timeouts', async () => {
+ mockSendMail.mockResolvedValue(mockSentMessageInfo);
+
+ await sendInviteEmail('friend@example.com', 'Alice');
+
+ expect(mockCreateTransport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ connectionTimeout: 10_000,
+ greetingTimeout: 10_000,
+ socketTimeout: 30_000,
+ }),
+ );
+ });
+
+ describe('when the send succeeds', () => {
+ it('resolves true when the email actually sends', async () => {
+ mockSendMail.mockResolvedValue(mockSentMessageInfo);
+
+ await expect(sendInviteEmail('friend@example.com', 'Alice')).resolves.toBe(true);
+ expect(mockSendMail).toHaveBeenCalledTimes(1);
+ });
+
+ it('escapes HTML special characters in the inviter name', async () => {
+ mockSendMail.mockResolvedValue(mockSentMessageInfo);
+
+ await sendInviteEmail('friend@example.com', `A&B "D" 'E'`);
+
+ const sentHtml = mockSendMail.mock.calls[0]?.[0]?.html;
+ expect(sentHtml).toEqual(
+ expect.stringContaining('A&B <C> "D" 'E''),
+ );
+ expect(sentHtml).not.toEqual(expect.stringContaining(`A&B "D" 'E'`));
+ });
+
+ it('does not alter or double-escape a plain alphanumeric name', async () => {
+ mockSendMail.mockResolvedValue(mockSentMessageInfo);
+
+ await sendInviteEmail('friend@example.com', 'Alice Smith');
+
+ const sentHtml = mockSendMail.mock.calls[0]?.[0]?.html;
+ expect(sentHtml).toEqual(expect.stringContaining('Alice Smith'));
+ });
+ });
+
+ describe('when the send fails', () => {
+ it('resolves false when the SMTP transport fails', async () => {
+ mockSendMail.mockRejectedValue(new Error('connect ECONNREFUSED'));
+
+ await expect(sendInviteEmail('friend@example.com', 'Alice')).resolves.toBe(false);
+ expect(mockSendToDiscord).toHaveBeenCalledTimes(1);
+ });
+
+ it('resolves false when reporting the SMTP failure to Discord also fails', async () => {
+ mockSendMail.mockRejectedValue(new Error('connect ECONNREFUSED'));
+ mockSendToDiscord.mockRejectedValue(new Error('Discord unavailable'));
+
+ await expect(sendInviteEmail('friend@example.com', 'Alice')).resolves.toBe(false);
+ });
+ });
+
+ describe('in development mode', () => {
+ it('skips sending and resolves true', async () => {
+ (env as { NODE_ENV: string }).NODE_ENV = 'development';
+
+ await expect(sendInviteEmail('friend@example.com', 'Alice')).resolves.toBe(true);
+ expect(mockCreateTransport).not.toHaveBeenCalled();
+ expect(mockSendMail).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when invites are disabled', () => {
+ it('still throws', async () => {
+ (env as { ENABLE_SENDING_INVITES: boolean }).ENABLE_SENDING_INVITES = false;
+
+ await expect(sendInviteEmail('friend@example.com', 'Alice')).rejects.toThrow(
+ 'Sending invites is not enabled',
+ );
+ });
+ });
+});