chore(repo): remove dead code and useless tests (#3375)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-10 21:43:29 +02:00
1 parent 7e177a1c90
commit 1cd62eb661
1391 files changed
+783 -120415

No files matched your search

-2
View File
@@ -64,8 +64,6 @@ export abstract class ICacheService {
abstract extendLock(key: string, token: string, ttlSeconds: number): Promise<boolean>;
abstract getAndRenewTtl<T>(key: string, newTtlSeconds: number): Promise<T | null>;
abstract publish(channel: string, message: string): Promise<void>;
abstract sadd(key: string, member: string, ttlSeconds?: number): Promise<void>;
@@ -226,15 +226,6 @@ export class InMemoryProvider extends ICacheService {
return true;
}
async getAndRenewTtl<T>(key: string, newTtlSeconds: number): Promise<T | null> {
const entry = this.getValueEntry(key);
if (!entry) {
return null;
}
entry.expiresAt = Date.now() + newTtlSeconds * 1000;
return entry.value as T;
}
async publish(_channel: string, _message: string): Promise<void> {
return;
}
@@ -192,12 +192,6 @@ export class KVCacheProvider extends ICacheService {
return await this.client.extendLock(lockKey, token, ttlSeconds);
}
async getAndRenewTtl<T>(key: string, newTtlSeconds: number): Promise<T | null> {
const value = await this.client.getex(key, newTtlSeconds);
if (value == null) return null;
return safeJsonParse<T>(value, this.logger);
}
async publish(channel: string, message: string): Promise<void> {
await this.client.publish(channel, message);
}
-10
View File
@@ -37,14 +37,12 @@ interface CassandraBatchOptions {
interface ICassandraClient {
connect(): Promise<void>;
shutdown(): Promise<void>;
isConnected(): boolean;
execute<P extends CassandraParams>(
query: PreparedQuery<P>,
options?: CassandraExecuteOptions,
): Promise<cassandra.types.ResultSet>;
batch(queries: Array<PreparedQuery>, options?: CassandraBatchOptions): Promise<void>;
getNativeClient(): cassandra.Client;
setLogger(logger: Logger): void;
}
interface DefaultClientState {
@@ -131,10 +129,6 @@ class CassandraClient implements ICassandraClient {
this.logger.info({}, 'Cassandra connection closed');
}
public isConnected(): boolean {
return this.client !== null;
}
public async execute<P extends CassandraParams>(
query: PreparedQuery<P>,
options: CassandraExecuteOptions = {},
@@ -160,10 +154,6 @@ class CassandraClient implements ICassandraClient {
}
return this.client;
}
public setLogger(logger: Logger): void {
this.logger = logger;
}
}
export async function initCassandra(config: CassandraConfig): Promise<void> {
@@ -1,394 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EmailI18nService, type IEmailI18nService} from '@pkgs/email/src/EmailI18nService';
import type {EmailConfig, EmailMessage, IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
import {EmailService} from '@pkgs/email/src/EmailService';
import {TestEmailService} from '@pkgs/email/src/TestEmailService';
import {describe, expect, it} from 'vitest';
const CONFIG: EmailConfig = {
enabled: true,
fromEmail: '[email protected]',
fromName: 'Fluxer',
appBaseUrl: 'https://example.com',
termsUrl: 'https://example.com/terms',
guidelinesUrl: 'https://example.com/guidelines',
appealsEmail: '[email protected]',
safetyEmail: '[email protected]',
supportEmail: '[email protected]',
productName: 'Fluxer',
};
async function sendWith(config: EmailConfig): Promise<EmailMessage> {
const sent: Array<EmailMessage> = [];
const provider: IEmailProvider = {
sendEmail: async (message) => {
sent.push(message);
return true;
},
};
const service = new EmailService(config, new EmailI18nService(), provider);
await expect(service.sendEmailChangeNew('[email protected]', 'testuser', '123456', 'en-US')).resolves.toBe(true);
expect(sent).toHaveLength(1);
return sent[0];
}
describe('EmailService reply-to', () => {
it('sets the configured reply-to address on every message', async () => {
const message = await sendWith({...CONFIG, replyTo: '[email protected]'});
expect(message.replyTo).toBe('[email protected]');
expect(message.from).toEqual({email: '[email protected]', name: 'Fluxer'});
});
it.each([undefined, null, ''])('omits the reply-to address when it is %j', async (replyTo) => {
const message = await sendWith({...CONFIG, replyTo});
expect(message).not.toHaveProperty('replyTo');
});
});
function createCapturingServiceFor(config: EmailConfig): {service: EmailService; sent: Array<EmailMessage>} {
const sent: Array<EmailMessage> = [];
const provider: IEmailProvider = {
sendEmail: async (message) => {
sent.push(message);
return true;
},
};
return {service: new EmailService(config, new EmailI18nService(), provider), sent};
}
function createCapturingService(): {service: EmailService; sent: Array<EmailMessage>} {
return createCapturingServiceFor(CONFIG);
}
describe('EmailService report notices', () => {
it('sends a receipt with the report id and target kind', async () => {
const {service, sent} = createCapturingService();
await expect(service.sendReportReceivedEmail('[email protected]', '1234567890', 'guild', 'en-US')).resolves.toBe(
true,
);
expect(sent).toHaveLength(1);
expect(sent[0].to).toBe('[email protected]');
expect(sent[0].subject).toBe('We received your Fluxer report');
expect(sent[0].text).toContain('report about a community on Fluxer.');
expect(sent[0].text).toContain('Report ID: 1234567890');
});
it('sends the receipt in the requested locale', async () => {
const {service, sent} = createCapturingService();
await service.sendReportReceivedEmail('[email protected]', '1234567890', 'message', 'de');
expect(sent[0].subject).toBe('Wir haben deine Fluxer-Meldung erhalten');
expect(sent[0].text).toContain('Meldungs-ID: 1234567890');
});
it('sends a DSA decision notice with the public comment', async () => {
const {service, sent} = createCapturingService();
await expect(
service.sendDsaReportResolvedEmail('[email protected]', '1234567890', 'We removed the content.', 'en-US'),
).resolves.toBe(true);
expect(sent).toHaveLength(1);
expect(sent[0].to).toBe('[email protected]');
expect(sent[0].subject).toBe('We made a decision on your Fluxer report');
expect(sent[0].text).toContain('(ID: 1234567890)');
expect(sent[0].text).toContain('Response from the Safety Team:\nWe removed the content.');
expect(sent[0].text).toContain('Email [email protected] from this email address');
});
it('sends a generic DSA decision notice without a public comment', async () => {
const {service, sent} = createCapturingService();
await service.sendDsaReportResolvedEmail('[email protected]', '1234567890', '', 'en-US');
expect(sent[0].text).not.toContain('Response from the Safety Team');
expect(sent[0].text).not.toContain('\n\n\n');
expect(sent[0].text).toContain('Email [email protected] from this email address');
});
});
describe('TestEmailService report notices', () => {
it('records the receipt and the DSA decision notice', async () => {
const service = new TestEmailService();
await service.sendReportReceivedEmail('[email protected]', '1234567890', 'user', 'en-US');
await service.sendDsaReportResolvedEmail('[email protected]', '1234567890', '', 'en-US');
expect(service.listSentEmails().map(({to, type, metadata}) => ({to, type, metadata}))).toEqual([
{to: '[email protected]', type: 'report_received', metadata: {report_id: '1234567890', target_kind: 'user'}},
{
to: '[email protected]',
type: 'dsa_report_resolved',
metadata: {report_id: '1234567890', public_comment: ''},
},
]);
});
});
describe('EmailService enforcement notices', () => {
function capture(config: EmailConfig): {service: EmailService; sent: Array<EmailMessage>} {
const sent: Array<EmailMessage> = [];
const provider: IEmailProvider = {
sendEmail: async (message) => {
sent.push(message);
return true;
},
};
return {service: new EmailService(config, new EmailI18nService(), provider), sent};
}
async function sendBoth(config: EmailConfig, reason: string | null = 'Spam'): Promise<Array<string>> {
const {service, sent} = capture(config);
const until = new Date('2026-10-01T23:30:00Z');
await service.sendAccountTempBannedEmail('[email protected]', 'testuser', reason, 24, until, 'en-US');
await service.sendAccountScheduledForDeletionEmail('[email protected]', 'testuser', reason, until, 'en-US');
expect(sent).toHaveLength(2);
return sent.map((message) => message.text);
}
it('links the configured terms and guidelines', async () => {
for (const text of await sendBoth(CONFIG)) {
expect(text).toContain(
'Please review:\n- Terms of Service: https://example.com/terms\n- Community Guidelines: https://example.com/guidelines\n\n',
);
}
});
it('leaves out the review list when no policy page is configured', async () => {
for (const text of await sendBoth({...CONFIG, termsUrl: null, guidelinesUrl: null})) {
expect(text).not.toContain('Please review:');
expect(text).not.toContain('https://');
expect(text).not.toContain('\n\n\n');
expect(text).toContain('[email protected]');
}
});
it('lists only the configured page', async () => {
for (const text of await sendBoth({...CONFIG, termsUrl: null, guidelinesUrl: 'https://rules.example.org'})) {
expect(text).toContain('Please review:\n- Community Guidelines: https://rules.example.org\n\n');
expect(text).not.toContain('Terms of Service:');
}
for (const text of await sendBoth({...CONFIG, termsUrl: 'https://tos.example.org', guidelinesUrl: null})) {
expect(text).toContain('Please review:\n- Terms of Service: https://tos.example.org\n\n');
expect(text).not.toContain('Community Guidelines:');
}
});
it('states the reason as its own paragraph', async () => {
const [suspended, deletion] = await sendBoth(CONFIG);
expect(suspended).toContain(' UTC\n\nReason: Spam\n\nDuring this time,');
expect(deletion).toContain(' UTC\n\nReason: Spam\n\nThis is a serious enforcement action.');
for (const text of [suspended, deletion]) {
expect(text).not.toContain('\n\n\n');
}
});
it.each([null, '', ' '])('leaves no gap when the reason is %j', async (reason) => {
const withReason = await sendBoth(CONFIG);
const [suspended, deletion] = await sendBoth(CONFIG, reason);
expect(suspended).toContain(' UTC\n\nDuring this time,');
expect(deletion).toContain(' UTC\n\nThis is a serious enforcement action.');
for (const [index, text] of [suspended, deletion].entries()) {
expect(text).not.toContain('Reason:');
expect(text).not.toContain('\n\n\n');
expect(text.split('\n\n')).toHaveLength(withReason[index].split('\n\n').length - 1);
}
});
});
describe('EmailService contact addresses', () => {
const SELF_HOSTED: EmailConfig = {...CONFIG, appealsEmail: null, safetyEmail: null, supportEmail: null};
const DATE = new Date('2026-10-01T23:30:00Z');
async function sendAll(config: EmailConfig, locale: string): Promise<Record<string, string>> {
const sent: Array<EmailMessage> = [];
const provider: IEmailProvider = {
sendEmail: async (message) => {
sent.push(message);
return true;
},
};
const service = new EmailService(config, new EmailI18nService(), provider);
const to = '[email protected]';
await service.sendAccountTempBannedEmail(to, 'testuser', 'Spam', 24, DATE, locale);
await service.sendAccountScheduledForDeletionEmail(to, 'testuser', 'Spam', DATE, locale);
await service.sendScheduledDeletionNotification(to, 'testuser', DATE, 'Spam', locale);
await service.sendDsaReportResolvedEmail(to, '1234567890', 'We removed the content.', locale);
await service.sendReportResolvedEmail(to, 'testuser', '1234567890', 'We removed the content.', locale);
await service.sendAccountDeletionCancelledEmail(to, 'testuser', locale);
await service.sendAccountDeletionRequestedEmail(to, 'testuser', null, DATE, locale);
await service.sendAccountDeletionInactivityEmail(to, 'testuser', null, DATE, locale);
expect(sent).toHaveLength(8);
const [tempBan, deletion, deletionNotice, dsaResolved, resolved, cancelled, requested, inactivity] = sent.map(
(message) => message.text,
);
return {tempBan, deletion, deletionNotice, dsaResolved, resolved, cancelled, requested, inactivity};
}
it('names the hosted mailboxes in en-US', async () => {
const hosted = await sendAll(CONFIG, 'en-US');
expect(hosted.tempBan).toContain(
'Email [email protected] from this email address and clearly explain why you believe the decision was incorrect.',
);
expect(hosted.deletion).toContain(
'you have 60 days to submit an appeal. Email [email protected] from this email address.\n',
);
expect(hosted.deletionNotice).toContain(
'you can submit an appeal. Email [email protected] from this email address.\n',
);
expect(hosted.dsaResolved).toContain('Email [email protected] from this email address, include your report ID,');
expect(hosted.resolved).toContain('please contact [email protected].\n');
expect(hosted.cancelled).toContain('If you have any questions, contact [email protected].\n');
expect(hosted.requested).toContain('contact [email protected] from this email address before that date.\n');
expect(hosted.inactivity).toContain('contact [email protected] from this email address before that date.\n');
});
it('points at the instance administrators in en-US when no mailbox is configured', async () => {
const neutral = await sendAll(SELF_HOSTED, 'en-US');
expect(neutral.tempBan).toContain(
'you can submit an appeal. Contact the administrators of this instance and clearly explain why you believe the decision was incorrect.',
);
expect(neutral.deletion).toContain(
'you have 60 days to submit an appeal. Contact the administrators of this instance.\n',
);
expect(neutral.deletionNotice).toContain(
'you can submit an appeal. Contact the administrators of this instance.\n',
);
expect(neutral.dsaResolved).toContain(
'Contact the administrators of this instance, include your report ID, and explain why you think the decision is wrong.',
);
expect(neutral.resolved).toContain(
'If you have any questions or concerns about this outcome, please contact the administrators of this instance.\n',
);
expect(neutral.cancelled).toContain('If you have any questions, contact the administrators of this instance.\n');
expect(neutral.requested).toContain(
"Your account is locked until then. If you didn't request this, or you want to keep your account, contact the administrators of this instance before that date.\n",
);
expect(neutral.inactivity).toContain(
'If you want to keep your account, contact the administrators of this instance before that date.\n',
);
});
it.each([
['de', 'die Administratoren dieser Instanz', 'Sende eine E-Mail von dieser E-Mail-Adresse an [email protected]'],
['ja', 'このインスタンスの管理者', 'このメールアドレスから[email protected]までメールを送信'],
])('renders both variants in %s', async (locale, administrators, hostedAppeal) => {
const hosted = await sendAll(CONFIG, locale);
const neutral = await sendAll(SELF_HOSTED, locale);
expect(hosted.tempBan).toContain(hostedAppeal);
for (const [name, text] of Object.entries(hosted)) {
expect(text, name).toMatch(/(appeals|safety)@fluxer\.com/);
expect(text, name).not.toContain(administrators);
}
for (const [name, text] of Object.entries(neutral)) {
expect(text, name).toContain(administrators);
expect(text, name).not.toContain('@');
expect(text, name).not.toMatch(/\bnull\b/);
expect(text, name).not.toContain('{');
expect(text, name).not.toContain('\n\n\n');
expect(text.split('\n'), name).toHaveLength(hosted[name].split('\n').length);
}
});
});
describe('EmailService support contact', () => {
const SELF_HOSTED: EmailConfig = {...CONFIG, appealsEmail: null, safetyEmail: null, supportEmail: null};
const DATE = new Date('2026-10-01T23:30:00Z');
async function sendSupportEmails(config: EmailConfig, locale: string): Promise<Array<string>> {
const {service, sent} = createCapturingServiceFor(config);
const to = '[email protected]';
await service.sendGiftChargebackNotification(to, 'testuser', locale);
await service.sendHarvestCompletedEmail(to, 'testuser', 'https://example.com/d', 10, 1024, DATE, locale);
await service.sendInactivityWarningEmail(to, 'testuser', DATE, DATE, locale);
expect(sent).toHaveLength(3);
return sent.map((message) => message.text);
}
it('keeps the support team sentences on the hosted instance', async () => {
const [gift, harvest, inactivity] = await sendSupportEmails(CONFIG, 'en-US');
expect(gift).toContain(
'\n\nIf you think this is a mistake, please contact our support team and include any details you have about the gift code and when you redeemed it.\n\n',
);
expect(harvest).toContain(
"\n\nIf you didn't request this export, please change your password immediately and contact our support team.\n\n",
);
expect(inactivity).toContain("\n\nIf you've used Fluxer recently, please contact our support team right away.\n\n");
});
it('points at the instance administrators when the instance has no support mailbox', async () => {
const [gift, harvest, inactivity] = await sendSupportEmails({...SELF_HOSTED, productName: 'Example Chat'}, 'en-US');
expect(gift).toContain(
'\n\nIf you think this is a mistake, please contact the administrators of this instance and include any details you have about the gift code and when you redeemed it.\n\n',
);
expect(harvest).toContain(
"\n\nIf you didn't request this export, please change your password immediately and contact the administrators of this instance.\n\n",
);
expect(inactivity).toContain(
"\n\nIf you've used Example Chat recently, please contact the administrators of this instance right away.\n\n",
);
for (const text of [gift, harvest, inactivity]) {
expect(text).not.toMatch(/support/i);
}
});
it.each([
['de', 'unser Support-Team', 'die Administratoren dieser Instanz'],
['ja', 'サポートチーム', 'このインスタンスの管理者'],
])('renders both variants in %s', async (locale, supportTeam, administrators) => {
const hosted = await sendSupportEmails(CONFIG, locale);
const neutral = await sendSupportEmails(SELF_HOSTED, locale);
for (const [index, text] of hosted.entries()) {
expect(text).toContain(supportTeam);
expect(text).not.toContain(administrators);
expect(neutral[index]).toContain(administrators);
expect(neutral[index]).not.toContain(supportTeam);
expect(neutral[index].split('\n')).toHaveLength(text.split('\n').length);
}
});
});
describe('EmailService product name', () => {
async function sendPasswordReset(config: EmailConfig, locale: string): Promise<EmailMessage> {
const {service, sent} = createCapturingServiceFor(config);
await expect(service.sendPasswordResetEmail('[email protected]', 'testuser', 'token', locale)).resolves.toBe(true);
expect(sent).toHaveLength(1);
return sent[0];
}
it('names Fluxer on the hosted instance', async () => {
const message = await sendPasswordReset(CONFIG, 'en-US');
expect(message.subject).toBe('Reset your Fluxer password');
expect(message.text).toContain('Fluxer');
});
it.each(['en-US', 'de', 'ja'])('names the configured instance in the %s subject and body', async (locale) => {
const message = await sendPasswordReset({...CONFIG, productName: 'Example Chat'}, locale);
expect(message.subject).toContain('Example Chat');
expect(message.text).toContain('Example Chat');
expect(`${message.subject}\n${message.text}`).not.toContain('Fluxer');
});
it('uses the name of the config it was built with, so a renamed instance sends the new name', async () => {
const before = await sendPasswordReset({...CONFIG, productName: 'Example Chat'}, 'en-US');
const after = await sendPasswordReset({...CONFIG, productName: 'Renamed Chat'}, 'en-US');
expect(before.subject).toBe('Reset your Example Chat password');
expect(after.subject).toBe('Reset your Renamed Chat password');
expect(after.text).not.toContain('Example Chat');
});
it('passes the name to every template the service sends', async () => {
const names: Array<string> = [];
const i18n = new EmailI18nService();
const recording: IEmailI18nService = {
getTemplate: (key, locale, variables, productName) => {
names.push(productName);
return i18n.getTemplate(key, locale, variables, productName);
},
};
const service = new EmailService({...CONFIG, productName: 'Example Chat'}, recording, {
sendEmail: async () => true,
});
const date = new Date('2026-10-01T23:30:00Z');
const to = '[email protected]';
await service.sendEmailVerification(to, 'testuser', 'token', 'en-US');
await service.sendAccountTempBannedEmail(to, 'testuser', 'Spam', 24, date, 'en-US');
await service.sendReportResolvedEmail(to, 'testuser', '1', 'Thanks', 'en-US');
await service.sendSelfDeletionScheduledEmail(to, 'testuser', date, 'en-US');
expect(names).toEqual(['Example Chat', 'Example Chat', 'Example Chat', 'Example Chat']);
});
});
@@ -1,44 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
import {beforeEach, describe, expect, it, vi} from 'vitest';
const {sendMail} = vi.hoisted(() => ({sendMail: vi.fn()}));
vi.mock('nodemailer', () => ({
default: {createTransport: () => ({sendMail, verify: vi.fn()})},
}));
const MESSAGE = {
to: '[email protected]',
from: {email: '[email protected]', name: 'Fluxer'},
subject: 'Subject',
text: 'Body',
};
function createProvider(): SmtpEmailProvider {
return new SmtpEmailProvider({host: 'smtp.example.com', port: 587, username: 'user', password: 'pass'});
}
describe('SmtpEmailProvider', () => {
beforeEach(() => {
sendMail.mockReset();
sendMail.mockResolvedValue({});
});
it('passes the reply-to address to nodemailer', async () => {
await expect(createProvider().sendEmail({...MESSAGE, replyTo: '[email protected]'})).resolves.toBe(true);
expect(sendMail).toHaveBeenCalledWith({
to: '[email protected]',
from: 'Fluxer <[email protected]>',
replyTo: '[email protected]',
subject: 'Subject',
text: 'Body',
});
});
it('omits the reply-to address when the message has none', async () => {
await expect(createProvider().sendEmail(MESSAGE)).resolves.toBe(true);
expect(sendMail.mock.calls[0][0]).not.toHaveProperty('replyTo');
});
});
@@ -1,14 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {extractMessageTemplatePlaceholders} from '@fluxer/i18n/src/runtime/MessageCatalogTypes';
import {EmailI18nService} from '@pkgs/email/src/EmailI18nService';
import type {EmailMessage, IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
import {EmailService} from '@pkgs/email/src/EmailService';
import {getEmailTemplate} from '@pkgs/email/src/email_i18n/EmailI18n';
import {EMAIL_I18N_LOCALE_MESSAGES} from '@pkgs/email/src/email_i18n/EmailI18nLocales';
import {EMAIL_I18N_MESSAGES} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
import type {EmailLegalLinks, EmailTemplateVariables} from '@pkgs/email/src/email_i18n/EmailI18nTypes';
import type {EmailTemplateVariables} from '@pkgs/email/src/email_i18n/EmailI18nTypes';
import type {EmailTemplateKey} from '@pkgs/email/src/email_i18n/EmailI18nTypes.generated';
import {describe, expect, it} from 'vitest';
@@ -100,31 +94,6 @@ const FIXTURE: {[K in EmailTemplateKey]: EmailTemplateVariables[K]} = {
unban_notification: {username: 'testuser', reason: 'Appeal accepted'},
};
const CONTACT_KEYS = [
'account_deletion_cancelled',
'account_deletion_scheduled_inactivity',
'account_deletion_scheduled_requested',
'account_scheduled_deletion',
'account_temp_banned',
'dsa_report_resolved',
'report_resolved',
'scheduled_deletion_notification',
] as const satisfies ReadonlyArray<EmailTemplateKey>;
const SUPPORT_KEYS = [
'gift_chargeback_notification',
'harvest_completed',
'inactivity_warning',
] as const satisfies ReadonlyArray<EmailTemplateKey>;
function renderBody<K extends EmailTemplateKey>(key: K, locale: string, variables: EmailTemplateVariables[K]): string {
const result = getEmailTemplate(key, locale, variables, 'Fluxer');
if (!result.ok) {
throw new Error(result.error.message);
}
return result.value.body;
}
describe('EmailI18n locale files', () => {
it.each(LOCALES)('%s loads without module errors', (locale) => {
const template = getEmailTemplate(
@@ -165,284 +134,4 @@ describe('EmailI18n locale files', () => {
expect(result.value.body, key).not.toContain('Coordinated Universal Time');
}
});
it('renders dates and times in UTC with a zone label', () => {
expect(renderBody('self_deletion_scheduled', 'en-US', {username: 'testuser', deletionDate: DATE})).toContain(
'Thursday, October 1, 2026 at 11:30 PM UTC',
);
});
it.each(['account_deletion_scheduled_requested', 'account_deletion_scheduled_inactivity'] as const)(
'%s makes no enforcement claim',
(key) => {
const body = renderBody(key, 'en-US', {...FIXTURE[key], reason: 'Some reason'});
expect(body).not.toContain('Terms of Service');
expect(body.toLowerCase()).not.toContain('appeal');
expect(body).toContain('Reason: Some reason');
},
);
it.each(['account_deletion_scheduled_requested', 'account_deletion_scheduled_inactivity'] as const)(
'%s leaves no gap without a reason',
(key) => {
const body = renderBody(key, 'en-US', {...FIXTURE[key], reason: null});
expect(body).not.toContain('Reason:');
expect(body).not.toContain('\n\n\n');
},
);
it.each(['en-US', ...LOCALES])('%s names each reported target kind in the receipt', (locale) => {
const bodies = (['message', 'user', 'guild'] as const).map((targetKind) =>
renderBody('report_received', locale, {reportId: '1234567890', targetKind}),
);
expect(new Set(bodies).size).toBe(3);
for (const body of bodies) {
expect(body).toContain('1234567890');
expect(body).not.toContain('targetKind');
}
});
it('renders the receipt for each target kind', () => {
expect(renderBody('report_received', 'en-US', {reportId: '1', targetKind: 'message'})).toContain(
'report about a message on Fluxer.',
);
expect(renderBody('report_received', 'en-US', {reportId: '1', targetKind: 'user'})).toContain(
'report about an account on Fluxer.',
);
expect(renderBody('report_received', 'en-US', {reportId: '1', targetKind: 'guild'})).toContain(
'report about a community on Fluxer.',
);
});
it.each(['en-US', ...LOCALES])('%s tells a DSA reporter how to challenge the decision', (locale) => {
const withComment = renderBody('dsa_report_resolved', locale, {
reportId: '1234567890',
publicComment: 'We removed the content.',
hasComment: 'yes',
appeals_email: '[email protected]',
});
const withoutComment = renderBody('dsa_report_resolved', locale, {
reportId: '1234567890',
publicComment: '',
hasComment: 'no',
appeals_email: '[email protected]',
});
for (const body of [withComment, withoutComment]) {
expect(body).toContain('1234567890');
expect(body).toContain('[email protected]');
expect(body).toContain('60');
expect(body).not.toContain('\n\n\n');
}
expect(withComment).toContain('We removed the content.');
expect(withComment.split('\n\n')).toHaveLength(withoutComment.split('\n\n').length + 1);
});
it.each(['en-US', ...LOCALES])('%s lists only the configured policy links in enforcement notices', (locale) => {
const terms = 'https://example.com/terms';
const guidelines = 'https://example.com/guidelines';
for (const key of ['account_temp_banned', 'account_scheduled_deletion'] as const) {
const render = (termsUrl: string | null, guidelinesUrl: string | null, legalLinks: EmailLegalLinks) =>
renderBody(key, locale, {...FIXTURE[key], termsUrl, guidelinesUrl, legalLinks});
const both = render(terms, guidelines, 'both');
const termsOnly = render(terms, null, 'terms');
const guidelinesOnly = render(null, guidelines, 'guidelines');
const none = render(null, null, 'none');
expect(both, key).toContain(terms);
expect(both, key).toContain(guidelines);
expect(termsOnly, key).toContain(terms);
expect(termsOnly, key).not.toContain(guidelines);
expect(guidelinesOnly, key).toContain(guidelines);
expect(guidelinesOnly, key).not.toContain(terms);
expect(none, key).not.toContain('https://');
const bullets = (body: string) => body.split('\n').filter((line) => line.startsWith('- ')).length;
expect(bullets(both) - bullets(none), key).toBe(2);
expect(bullets(termsOnly) - bullets(none), key).toBe(1);
expect(bullets(guidelinesOnly) - bullets(none), key).toBe(1);
for (const body of [both, termsOnly, guidelinesOnly, none]) {
expect(body, key).not.toContain('\n\n\n');
expect(body, key).not.toContain('legalLinks');
expect(body, key).toContain('[email protected]');
}
expect(termsOnly.split('\n'), key).toHaveLength(both.split('\n').length - 1);
expect(guidelinesOnly.split('\n'), key).toHaveLength(both.split('\n').length - 1);
expect(none.split('\n\n'), key).toHaveLength(both.split('\n\n').length - 1);
}
});
it.each(['en-US', ...LOCALES])('%s leaves no gap in enforcement notices without a reason', (locale) => {
for (const key of ['account_temp_banned', 'account_scheduled_deletion'] as const) {
for (const legalLinks of ['both', 'none'] as const) {
const withReason = renderBody(key, locale, {...FIXTURE[key], legalLinks, reason: 'Repeated spam'});
const withoutReason = renderBody(key, locale, {...FIXTURE[key], legalLinks, reason: null});
expect(withReason, key).toContain('Repeated spam\n\n');
expect(withoutReason, key).not.toContain('Repeated spam');
for (const body of [withReason, withoutReason]) {
expect(body, key).not.toContain('\n\n\n');
expect(body, key).not.toContain('{');
}
expect(withoutReason.split('\n\n'), key).toHaveLength(withReason.split('\n\n').length - 1);
expect(withoutReason.split('\n'), key).toHaveLength(withReason.split('\n').length - 2);
}
}
});
it.each(['en-US', ...LOCALES])('%s names no mailbox when the instance has none', (locale) => {
const english = (key: (typeof CONTACT_KEYS)[number], variables: object) =>
renderBody(key, 'en-US', {...FIXTURE[key], ...variables} as never);
for (const key of CONTACT_KEYS) {
const hosted = renderBody(key, locale, FIXTURE[key] as never);
const neutral = renderBody(key, locale, {...FIXTURE[key], appeals_email: null, safety_email: null} as never);
expect(hosted, key).toMatch(/(appeals|safety)@fluxer\.com/);
expect(neutral, key).not.toContain('@');
expect(neutral, key).not.toMatch(/\bnull\b/);
expect(neutral, key).not.toContain('{');
expect(neutral, key).not.toContain('\n\n\n');
expect(neutral, key).not.toBe(hosted);
expect(neutral.split('\n'), key).toHaveLength(hosted.split('\n').length);
if (locale !== 'en-US' && locale !== 'en-GB') {
const neutralLine = neutral.split('\n').find((line, index) => line !== hosted.split('\n')[index]);
const englishNeutral = english(key, {appeals_email: null, safety_email: null}).split('\n');
expect(neutralLine, key).toBeDefined();
expect(englishNeutral, key).not.toContain(neutralLine);
expect(neutralLine, key).not.toContain('the administrators of this instance');
}
}
});
it.each(['en-US', ...LOCALES])('%s points at the instance administrators instead of a support team', (locale) => {
for (const key of SUPPORT_KEYS) {
const hosted = renderBody(key, locale, FIXTURE[key] as never);
const neutral = renderBody(key, locale, {...FIXTURE[key], support_email: null} as never);
const hostedLines = hosted.split('\n');
const neutralLines = neutral.split('\n');
expect(neutralLines, key).toHaveLength(hostedLines.length);
const changed = neutralLines.filter((line, index) => line !== hostedLines[index]);
expect(changed, key).toHaveLength(1);
expect(neutral, key).not.toMatch(/\bnull\b/);
expect(neutral, key).not.toContain('{');
expect(neutral, key).not.toContain('@');
expect(changed[0], key).not.toMatch(/support/i);
if (locale !== 'en-US' && locale !== 'en-GB') {
const englishNeutral = renderBody(key, 'en-US', {...FIXTURE[key], support_email: null} as never).split('\n');
expect(englishNeutral, key).not.toContain(changed[0]);
expect(changed[0], key).not.toContain('the administrators of this instance');
}
}
});
it.each(['en-US', ...LOCALES])('%s leaves no gap in deletion and unban notices without a reason', (locale) => {
for (const key of ['scheduled_deletion_notification', 'unban_notification'] as const) {
const withReason = renderBody(key, locale, {...FIXTURE[key], reason: 'Repeated spam'});
const withoutReason = renderBody(key, locale, {...FIXTURE[key], reason: null});
expect(withReason, key).toContain('Repeated spam\n\n');
expect(withoutReason, key).not.toContain('Repeated spam');
for (const body of [withReason, withoutReason]) {
expect(body, key).not.toContain('\n\n\n');
}
expect(withoutReason.split('\n\n'), key).toHaveLength(withReason.split('\n\n').length - 1);
}
});
it('greets a DSA reporter without a username', () => {
for (const key of ['report_received', 'dsa_report_resolved'] as const) {
const result = getEmailTemplate(key, 'en-US', FIXTURE[key], 'Fluxer');
expect(result.ok).toBe(true);
if (!result.ok) continue;
expect(result.value.body.startsWith('Hello,\n\n')).toBe(true);
expect(result.value.body).not.toContain('{');
}
});
it('renders a blank reason the same as no reason', async () => {
const sent: Array<EmailMessage> = [];
const provider: IEmailProvider = {
sendEmail: async (message) => {
sent.push(message);
return true;
},
};
const service = new EmailService(
{
enabled: true,
fromEmail: '[email protected]',
fromName: 'Fluxer',
appBaseUrl: 'https://example.com',
termsUrl: 'https://example.com/terms',
guidelinesUrl: 'https://example.com/guidelines',
appealsEmail: '[email protected]',
safetyEmail: '[email protected]',
supportEmail: '[email protected]',
productName: 'Fluxer',
},
new EmailI18nService(),
provider,
);
await service.sendUnbanNotification('[email protected]', 'testuser', ' ', 'en-US');
await service.sendUnbanNotification('[email protected]', 'testuser', null, 'en-US');
await service.sendAccountDeletionRequestedEmail('[email protected]', 'testuser', ' ', DATE, 'en-US');
await service.sendAccountDeletionRequestedEmail('[email protected]', 'testuser', null, DATE, 'en-US');
expect(sent).toHaveLength(4);
expect(sent[0].text).toBe(sent[1].text);
expect(sent[0].text).not.toContain('Reason:');
expect(sent[2].text).toBe(sent[3].text);
expect(sent[2].text).not.toContain('Reason:');
});
});
const HOSTED_RENDER_SHA256 = {
email_verification: {
'en-US': 'ea69bd530e1f9cf7cd04bc88d5c0a032069b2538bc5138eeee5bcea7dd21f539',
de: 'beb941104326bf73a523de0ccd33b78d3b531f461eea6ce1fae37a88634e69fa',
ja: 'e928c0d57baf6c1efd055d88661f6dc27f24119b0b972cafe614fed40663efb1',
},
password_reset: {
'en-US': 'b37a3491ef9126f251fd01fe2aa642891eb50aa56c23c36d0d62ccd0a9a7785d',
de: '148b9fbb52845befb1b9f24a063dafe2d5f000abc2104410d4e943ce56191917',
ja: 'd4198d68c126ad796954010431fe966b4df2b2fbabb9b4ddd5da0fbfd3b645a5',
},
ip_authorization: {
'en-US': '073ead7d3e270c886b44db16266f6cfb4a5b8b182d279a8bf17ddea49198b415',
de: 'd1c38f6a791a87379ca5b67e270d66e3741db314933b0b68c10bb9aab1781de1',
ja: 'adc16933f5efda8b176d7348942efaf6b2bff16bd09f14de9a48d727a76f011f',
},
gift_chargeback_notification: {
'en-US': '4ec08598e9087578ba52aa2490a1198dbfe68dd3bd496f4175b3b8ce533a01ac',
de: 'dd5a7f572a9063e26e5effa05193185286e28b5e6e256f3d0a98b97e7e31e952',
ja: 'c8d3a0fce144d278303f3d36c919d17bd60ab0738578f7e0f3e15e79bdb2bf19',
},
unban_notification: {
'en-US': '0cca736b44b8e1839bf3a1c9c8720c915ea80f40596801c57a9809c40c35c206',
de: '926e3762019888815da2ce7c70e94661878b9fba417bf0ffb11836abb2b74904',
ja: 'f5546b66f61d992aff2367c7b930d7683cd7a37891a3c2a069a8de2769113bd6',
},
} as const satisfies Partial<Record<EmailTemplateKey, Record<'en-US' | 'de' | 'ja', string>>>;
function renderEmail(key: EmailTemplateKey, locale: string, productName: string): {subject: string; body: string} {
const result = getEmailTemplate(key, locale, FIXTURE[key], productName);
if (!result.ok) {
throw new Error(result.error.message);
}
return result.value;
}
describe('EmailI18n product name', () => {
it('renders the hosted name byte for byte as before the name became a parameter', () => {
for (const [key, locales] of Object.entries(HOSTED_RENDER_SHA256)) {
for (const [locale, expected] of Object.entries(locales)) {
const {subject, body} = renderEmail(key as EmailTemplateKey, locale, 'Fluxer');
const actual = createHash('sha256').update(`${subject}\n${body}`).digest('hex');
expect(actual, `${key} ${locale}`).toBe(expected);
}
}
});
it.each(['en-US', ...LOCALES])('%s names the instance and never Fluxer in every template', (locale) => {
for (const key of TEMPLATE_KEYS) {
const {subject, body} = renderEmail(key, locale, 'Example Chat');
const rendered = `${subject}\n${body}`;
expect(rendered, key).toContain('Example Chat');
expect(rendered, key).not.toContain('Fluxer');
expect(rendered, key).not.toContain('{product_name}');
}
});
it.each(['en-US', ...LOCALES])('%s differs from the hosted render only by the name', (locale) => {
for (const key of TEMPLATE_KEYS) {
const hosted = renderEmail(key, locale, 'Fluxer');
const selfHosted = renderEmail(key, locale, 'Example Chat');
expect(selfHosted.subject.replaceAll('Example Chat', 'Fluxer'), key).toBe(hosted.subject);
expect(selfHosted.body.replaceAll('Example Chat', 'Fluxer'), key).toBe(hosted.body);
}
});
it('prints a name with message syntax characters as written', () => {
const {subject, body} = renderEmail('password_reset', 'en-US', "Bob's {Chat} #1");
expect(`${subject}\n${body}`).toContain("Bob's {Chat} #1");
});
});
@@ -1,9 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {HttpStatus} from '@fluxer/constants/src/HttpConstants';
import {createTestServer, readRequestBody, type TestServer} from '@pkgs/http_client/src/__tests__/TestHttpServer';
import {createTestServer, type TestServer} from '@pkgs/http_client/src/__tests__/TestHttpServer';
import {createHttpClient} from '@pkgs/http_client/src/HttpClient';
import type {HttpClientMetrics, HttpClientTelemetry} from '@pkgs/http_client/src/HttpClientTelemetryTypes';
import type {RequestUrlPolicy, RequestUrlValidationContext} from '@pkgs/http_client/src/HttpClientTypes';
import {HttpError} from '@pkgs/http_client/src/HttpError';
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
@@ -21,329 +17,8 @@ describe('HttpClient', () => {
await testServer.close();
await redirectServer.close();
});
describe('createHttpClient', () => {
it('creates an HTTP client with the provided user agent', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(req.headers['user-agent'] ?? '');
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(body).toBe(TEST_USER_AGENT);
});
});
describe('sendRequest', () => {
describe('basic requests', () => {
it('sends a GET request by default', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('GET');
});
it('sends a POST request with body', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const requestBody = {message: 'Hello, World!'};
testServer.setHandler(async (req, res) => {
const body = await readRequestBody(req);
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method, receivedBody: JSON.parse(body)}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: requestBody,
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('POST');
expect(json.receivedBody).toEqual(requestBody);
});
it('sends URLSearchParams bodies as form-encoded payloads', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const requestBody = new URLSearchParams({
grant_type: 'authorization_code',
code: 'test-code',
});
testServer.setHandler(async (req, res) => {
const body = await readRequestBody(req);
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(
JSON.stringify({
method: req.method,
receivedBody: body,
contentType: req.headers['content-type'],
}),
);
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: requestBody,
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('POST');
expect(json.receivedBody).toBe(requestBody.toString());
expect(json.contentType).toContain('application/x-www-form-urlencoded');
});
it('sends a HEAD request', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain', 'X-Custom-Header': 'test-value'});
res.end();
});
const response = await client.sendRequest({
url: testServer.url,
method: 'HEAD',
});
expect(response.status).toBe(200);
expect(response.headers.get('X-Custom-Header')).toBe('test-value');
});
});
describe('headers', () => {
it('sends default headers including user agent', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(
JSON.stringify({
userAgent: req.headers['user-agent'],
accept: req.headers['accept'],
cacheControl: req.headers['cache-control'],
pragma: req.headers['pragma'],
}),
);
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(json.userAgent).toBe(TEST_USER_AGENT);
expect(json.accept).toBe('*/*');
expect(json.cacheControl).toBe('no-cache, no-store, must-revalidate');
expect(json.pragma).toBe('no-cache');
});
it('allows custom headers to override defaults', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(
JSON.stringify({
accept: req.headers['accept'],
customHeader: req.headers['x-custom-header'],
}),
);
});
const response = await client.sendRequest({
url: testServer.url,
headers: {
Accept: 'application/json',
'X-Custom-Header': 'custom-value',
},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(json.accept).toBe('application/json');
expect(json.customHeader).toBe('custom-value');
});
it('normalizes response headers correctly', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(200, {
'Content-Type': 'text/plain',
'X-Single-Value': 'single',
});
res.end('OK');
});
const response = await client.sendRequest({url: testServer.url});
expect(response.headers.get('content-type')).toBe('text/plain');
expect(response.headers.get('x-single-value')).toBe('single');
});
});
describe('status codes', () => {
it('returns 200 status for successful requests', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
const response = await client.sendRequest({url: testServer.url});
expect(response.status).toBe(200);
});
it('returns 404 status for not found', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(404);
res.end('Not Found');
});
const response = await client.sendRequest({url: testServer.url});
expect(response.status).toBe(404);
});
it('returns 500 status for server errors', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(500);
res.end('Internal Server Error');
});
const response = await client.sendRequest({url: testServer.url});
expect(response.status).toBe(500);
});
it('handles 304 Not Modified without following redirects', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(HttpStatus.NOT_MODIFIED);
res.end();
});
const response = await client.sendRequest({url: testServer.url});
expect(response.status).toBe(HttpStatus.NOT_MODIFIED);
expect(response.url).toBe(new URL(testServer.url).href);
});
});
describe('redirects', () => {
it('follows 301 redirect', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(301, {Location: `${redirectServer.url}/target`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({path: req.url, method: req.method}));
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(response.url).toBe(`${redirectServer.url}/target`);
expect(json.path).toBe('/target');
});
it('follows 301 redirect and changes POST to GET', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(301, {Location: `${redirectServer.url}/moved`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {value: 'test'},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('GET');
});
it('follows 302 redirect', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(302, {Location: `${redirectServer.url}/found`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(req.url ?? '');
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(response.status).toBe(200);
expect(body).toBe('/found');
});
it('follows 302 redirect preserving PATCH', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(302, {Location: `${redirectServer.url}/found`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'PATCH',
body: {value: 'test'},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('PATCH');
});
it('follows 303 redirect and changes method to GET', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(303, {Location: `${redirectServer.url}/see-other`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {data: 'test'},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('GET');
});
it('drops content headers when redirect switches to GET', async () => {
const client = createHttpClient(TEST_USER_AGENT);
let contentType: string | undefined;
let contentLength: string | undefined;
testServer.setHandler((_req, res) => {
res.writeHead(303, {Location: `${redirectServer.url}/see-other`});
res.end();
});
redirectServer.setHandler((req, res) => {
contentType = req.headers['content-type'] as string | undefined;
contentLength = req.headers['content-length'] as string | undefined;
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('OK');
});
await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {data: 'test'},
});
expect(contentType).toBeUndefined();
expect(contentLength).toBeUndefined();
});
it('drops content headers when 301 redirect switches to GET', async () => {
const client = createHttpClient(TEST_USER_AGENT);
let contentType: string | undefined;
let contentLength: string | undefined;
testServer.setHandler((_req, res) => {
res.writeHead(301, {Location: `${redirectServer.url}/moved`});
res.end();
});
redirectServer.setHandler((req, res) => {
contentType = req.headers['content-type'] as string | undefined;
contentLength = req.headers['content-length'] as string | undefined;
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('OK');
});
await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {data: 'test'},
});
expect(contentType).toBeUndefined();
expect(contentLength).toBeUndefined();
});
it('strips sensitive headers on cross-origin redirects', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const secretToken = 'Bearer ultra-secret-token';
@@ -399,65 +74,6 @@ describe('HttpClient', () => {
});
expect(receivedAuthorization).toBe(secretToken);
});
it('follows 307 redirect preserving method', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(307, {Location: `${redirectServer.url}/temp`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {data: 'test'},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('POST');
});
it('follows 308 redirect preserving method', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(308, {Location: `${redirectServer.url}/permanent`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {data: 'test'},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('POST');
});
it('follows multiple redirects up to max limit', async () => {
const client = createHttpClient(TEST_USER_AGENT);
let redirectCount = 0;
testServer.setHandler((_req, res) => {
redirectCount++;
if (redirectCount < 5) {
res.writeHead(302, {Location: `${testServer.url}/redirect${redirectCount}`});
res.end();
} else {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({redirectCount}));
}
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.redirectCount).toBe(5);
});
it('throws error when exceeding max redirects', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
@@ -468,35 +84,6 @@ describe('HttpClient', () => {
'Maximum number of redirects (5) exceeded',
);
});
it('throws error when redirect has no Location header', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(302);
res.end();
});
await expect(client.sendRequest({url: testServer.url})).rejects.toThrow(
'Received redirect response without Location header',
);
});
it('handles relative redirect URLs', async () => {
const client = createHttpClient(TEST_USER_AGENT);
let requestCount = 0;
testServer.setHandler((req, res) => {
requestCount++;
if (requestCount === 1) {
res.writeHead(302, {Location: '/relative-path'});
res.end();
} else {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({path: req.url}));
}
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.path).toBe('/relative-path');
});
it('validates redirect targets with request URL policy before following', async () => {
const validationCalls: Array<RequestUrlValidationContext> = [];
const requestUrlPolicy: RequestUrlPolicy = {
@@ -526,15 +113,6 @@ describe('HttpClient', () => {
});
});
describe('timeout', () => {
it('uses default timeout of 30 seconds', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
const response = await client.sendRequest({url: testServer.url});
expect(response.status).toBe(200);
});
it('respects custom timeout', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
@@ -551,248 +129,5 @@ describe('HttpClient', () => {
).rejects.toThrow();
});
});
describe('abort signal', () => {
it('aborts request when signal is triggered', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const controller = new AbortController();
testServer.setHandler((_req, res) => {
setTimeout(() => {
res.writeHead(200);
res.end('OK');
}, 1000);
});
const requestPromise = client.sendRequest({
url: testServer.url,
signal: controller.signal,
});
setTimeout(() => controller.abort(), 50);
await expect(requestPromise).rejects.toThrow();
});
it('handles pre-aborted signal', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const controller = new AbortController();
controller.abort('Pre-aborted');
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
await expect(
client.sendRequest({
url: testServer.url,
signal: controller.signal,
}),
).rejects.toThrow();
});
});
describe('error handling', () => {
it('throws HttpError for connection refused', async () => {
const client = createHttpClient(TEST_USER_AGENT);
try {
await client.sendRequest({url: 'http://127.0.0.1:1'});
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(HttpError);
const httpError = error as HttpError;
expect(httpError.isExpected).toBe(true);
}
});
it('throws HttpError for DNS resolution failure', async () => {
const client = createHttpClient(TEST_USER_AGENT);
try {
await client.sendRequest({url: 'http://this-domain-does-not-exist-12345.invalid'});
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(HttpError);
const httpError = error as HttpError;
expect(httpError.isExpected).toBe(true);
}
});
});
});
describe('streamToString', () => {
it('converts response stream to string', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello, World!');
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(body).toBe('Hello, World!');
});
it('handles empty response body', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(204);
res.end();
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(body).toBe('');
});
it('handles large response body', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const largeContent = 'x'.repeat(1024 * 1024);
testServer.setHandler((_req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(largeContent);
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(body.length).toBe(largeContent.length);
});
it('handles UTF-8 content correctly', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const unicodeContent = 'Hello, World! Emoji: \u{1F600} Chinese: \u4E2D\u6587 Arabic: \u0639\u0631\u0628\u064A';
testServer.setHandler((_req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain; charset=utf-8'});
res.end(unicodeContent);
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(body).toBe(unicodeContent);
});
});
describe('telemetry', () => {
it('records metrics for successful requests', async () => {
const recordedMetrics: Array<{
type: string;
name: string;
dimensions?: Record<string, string>;
}> = [];
const metrics: HttpClientMetrics = {
counter: (params) => {
recordedMetrics.push({type: 'counter', ...params});
},
histogram: (params) => {
recordedMetrics.push({type: 'histogram', ...params});
},
};
const telemetry: HttpClientTelemetry = {metrics};
const client = createHttpClient(TEST_USER_AGENT, telemetry);
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
await client.sendRequest({url: testServer.url, serviceName: 'test-service'});
const latencyMetric = recordedMetrics.find((m) => m.name === 'http_client.latency');
const requestMetric = recordedMetrics.find((m) => m.name === 'http_client.request');
const responseMetric = recordedMetrics.find((m) => m.name === 'http_client.response');
expect(latencyMetric).toBeDefined();
expect(latencyMetric?.dimensions?.service).toBe('test-service');
expect(latencyMetric?.dimensions?.method).toBe('GET');
expect(requestMetric).toBeDefined();
expect(requestMetric?.dimensions?.service).toBe('test-service');
expect(requestMetric?.dimensions?.status).toBe('2xx');
expect(responseMetric).toBeDefined();
expect(responseMetric?.dimensions?.service).toBe('test-service');
expect(responseMetric?.dimensions?.status_code).toBe('2xx');
});
it('records metrics for error responses', async () => {
const recordedMetrics: Array<{
type: string;
name: string;
dimensions?: Record<string, string>;
}> = [];
const metrics: HttpClientMetrics = {
counter: (params) => {
recordedMetrics.push({type: 'counter', ...params});
},
histogram: (params) => {
recordedMetrics.push({type: 'histogram', ...params});
},
};
const telemetry: HttpClientTelemetry = {metrics};
const client = createHttpClient(TEST_USER_AGENT, telemetry);
testServer.setHandler((_req, res) => {
res.writeHead(404);
res.end('Not Found');
});
await client.sendRequest({url: testServer.url, serviceName: 'test-service'});
const requestMetric = recordedMetrics.find((m) => m.name === 'http_client.request');
expect(requestMetric?.dimensions?.status).toBe('404');
});
it('records metrics for network errors', async () => {
const recordedMetrics: Array<{
type: string;
name: string;
dimensions?: Record<string, string>;
}> = [];
const metrics: HttpClientMetrics = {
counter: (params) => {
recordedMetrics.push({type: 'counter', ...params});
},
histogram: (params) => {
recordedMetrics.push({type: 'histogram', ...params});
},
};
const telemetry: HttpClientTelemetry = {metrics};
const client = createHttpClient(TEST_USER_AGENT, telemetry);
try {
await client.sendRequest({url: 'http://127.0.0.1:1', serviceName: 'test-service'});
} catch {}
const requestMetric = recordedMetrics.find((m) => m.name === 'http_client.request');
expect(requestMetric?.dimensions?.status).toBe('network_error');
});
it('uses default service name when not provided', async () => {
const recordedMetrics: Array<{
type: string;
name: string;
dimensions?: Record<string, string>;
}> = [];
const metrics: HttpClientMetrics = {
counter: (params) => {
recordedMetrics.push({type: 'counter', ...params});
},
histogram: (params) => {
recordedMetrics.push({type: 'histogram', ...params});
},
};
const telemetry: HttpClientTelemetry = {metrics};
const client = createHttpClient(TEST_USER_AGENT, telemetry);
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
await client.sendRequest({url: testServer.url});
const latencyMetric = recordedMetrics.find((m) => m.name === 'http_client.latency');
expect(latencyMetric?.dimensions?.service).toBe('unknown');
});
});
});
describe('HttpError', () => {
it('creates error with message', () => {
const error = new HttpError('Test error');
expect(error.message).toBe('Test error');
expect(error.name).toBe('HttpError');
expect(error.status).toBeUndefined();
expect(error.response).toBeUndefined();
expect(error.isExpected).toBe(false);
expect(error.errorType).toBeUndefined();
});
it('creates error with status code', () => {
const error = new HttpError('Not Found', 404);
expect(error.message).toBe('Not Found');
expect(error.status).toBe(404);
});
it('creates error with response', () => {
const response = new Response('Error body', {status: 500});
const error = new HttpError('Server Error', 500, response);
expect(error.status).toBe(500);
expect(error.response).toBe(response);
});
it('creates error with isExpected flag', () => {
const error = new HttpError('Expected error', 400, undefined, true);
expect(error.isExpected).toBe(true);
});
it('creates error with errorType', () => {
const error = new HttpError('Aborted', undefined, undefined, false, 'aborted');
expect(error.errorType).toBe('aborted');
});
it('is an instance of Error', () => {
const error = new HttpError('Test');
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(HttpError);
});
});
@@ -54,12 +54,3 @@ export async function createTestServer(): Promise<TestServer> {
});
});
}
export function readRequestBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Array<Buffer> = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
req.on('error', reject);
});
}
@@ -25,7 +25,6 @@ export interface IKVSubscription {
unsubscribe(...channels: Array<string>): Promise<void>;
quit(): Promise<void>;
disconnect(): Promise<void>;
removeAllListeners(event?: 'message' | 'error'): void;
}
export interface KVPurgeBatchResult {
@@ -78,22 +77,13 @@ export interface IKVProvider {
ltrim(key: string, start: number, stop: number): Promise<void>;
hset(key: string, field: string, value: string): Promise<number>;
hdel(key: string, ...fields: Array<string>): Promise<number>;
hget(key: string, field: string): Promise<string | null>;
hgetall(key: string): Promise<Record<string, string>>;
publish(channel: string, message: string): Promise<number>;
duplicate(): IKVSubscription;
acquireLock(key: string, token: string, ttlSeconds: number): Promise<boolean>;
releaseLock(key: string, token: string): Promise<boolean>;
extendLock(key: string, token: string, ttlSeconds: number): Promise<boolean>;
renewSnowflakeNode(key: string, instanceId: string, ttlSeconds: number): Promise<boolean>;
checkLeakyBucketLimit(key: string, limit: number, windowMs: number, cost: number): Promise<KVRateLimitResult>;
tryConsumeTokens(
key: string,
requested: number,
maxTokens: number,
refillRate: number,
refillIntervalMs: number,
): Promise<number>;
scheduleBulkDeletion(queueKey: string, secondaryKey: string, score: number, value: string): Promise<void>;
claimBulkDeletion(queueKey: string, member: string, maxScore: number, leaseScore: number): Promise<boolean>;
removeBulkDeletion(queueKey: string, secondaryKey: string, member?: string): Promise<boolean>;
@@ -109,5 +99,4 @@ export interface IKVProvider {
pipeline(): IKVPipeline;
multi(): IKVPipeline;
isClustered(): boolean;
health(): Promise<boolean>;
}
-96
View File
@@ -65,52 +65,6 @@ if redis.call('GET', KEYS[1]) == ARGV[1] then
end
return 0
`;
const RENEW_SNOWFLAKE_SCRIPT = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
return 1
end
return 0
`;
const TRY_CONSUME_TOKENS_SCRIPT = `
${DECODE_BUCKET_STATE_SCRIPT}
local key = KEYS[1]
local now = tonumber(ARGV[1])
local requested = tonumber(ARGV[2])
local maxTokens = tonumber(ARGV[3])
local refillRate = tonumber(ARGV[4])
local refillIntervalMs = tonumber(ARGV[5])
local data = redis.call('GET', key)
local tokens = maxTokens
local lastRefill = now
if data then
tokens, lastRefill = decodeBucketState(data, 'tokens', 'lastRefill')
end
local elapsed = now - lastRefill
if elapsed >= refillIntervalMs then
local intervals = math.floor(elapsed / refillIntervalMs)
local tokensToAdd = intervals * refillRate
if tokensToAdd > 0 then
tokens = math.min(maxTokens, tokens + tokensToAdd)
lastRefill = now
end
end
local consumed = 0
if tokens >= requested then
consumed = requested
tokens = tokens - requested
elseif tokens > 0 then
consumed = tokens
tokens = 0
end
redis.call('SET', key, cjson.encode({tokens = tokens, lastRefill = lastRefill}), 'EX', 3600)
return consumed
`;
const CHECK_LEAKY_BUCKET_LIMIT_SCRIPT = `
${DECODE_BUCKET_STATE_SCRIPT}
local key = KEYS[1]
@@ -324,15 +278,6 @@ export class KVClient implements IKVProvider {
this.client.disconnect(false);
}
async health(): Promise<boolean> {
try {
return (await this.execute('health', async () => this.client.ping())) === 'PONG';
} catch (error) {
this.logger.debug({error}, 'KV health check failed');
return false;
}
}
async get(key: string): Promise<string | null> {
return await this.execute('get', async () => this.client.get(key));
}
@@ -571,10 +516,6 @@ export class KVClient implements IKVProvider {
return await this.execute('hdel', async () => this.client.hdel(key, ...fields));
}
async hget(key: string, field: string): Promise<string | null> {
return await this.execute('hget', async () => this.client.hget(key, field));
}
async hgetall(key: string): Promise<Record<string, string>> {
return await this.execute('hgetall', async () => this.client.hgetall(key));
}
@@ -611,18 +552,6 @@ export class KVClient implements IKVProvider {
return parseIntegerDecision(result, 'extendLock');
}
async renewSnowflakeNode(key: string, instanceId: string, ttlSeconds: number): Promise<boolean> {
const result = await this.executeScript(
'renewSnowflakeNode',
RENEW_SNOWFLAKE_SCRIPT,
1,
key,
instanceId,
ttlSeconds,
);
return parseIntegerDecision(result, 'renewSnowflakeNode');
}
async checkLeakyBucketLimit(key: string, limit: number, windowMs: number, cost: number): Promise<KVRateLimitResult> {
const result = await this.executeJsonScript(
'checkLeakyBucketLimit',
@@ -637,31 +566,6 @@ export class KVClient implements IKVProvider {
return parseRateLimitResult(result);
}
async tryConsumeTokens(
key: string,
requested: number,
maxTokens: number,
refillRate: number,
refillIntervalMs: number,
): Promise<number> {
const now = Date.now();
const result = await this.executeScript(
'tryConsumeTokens',
TRY_CONSUME_TOKENS_SCRIPT,
1,
key,
now,
requested,
maxTokens,
refillRate,
refillIntervalMs,
);
if (!isNonNegativeSafeInteger(result) || result > requested) {
throw createInvalidResponseError('tryConsumeTokens', 'an integer token count within the requested amount');
}
return result;
}
async scheduleBulkDeletion(queueKey: string, secondaryKey: string, score: number, value: string): Promise<void> {
const result = await this.executeScript(
'scheduleBulkDeletion',
@@ -258,15 +258,6 @@ export class KVSubscription implements IKVSubscription {
}
client.disconnect(false);
}
removeAllListeners(event?: 'message' | 'error'): void {
if (!event || event === 'message') {
this.messageCallbacks.clear();
}
if (!event || event === 'error') {
this.errorCallbacks.clear();
}
}
}
function createRetryStrategy(): (times: number) => number {
@@ -115,24 +115,12 @@ describe('KVClient script execution', () => {
keyCount: 1,
run: async (client) => client.extendLock('lock:key', 'token', 30),
},
{
name: 'renewSnowflakeNode',
reply: 1,
keyCount: 1,
run: async (client) => client.renewSnowflakeNode('snowflake:1', 'instance', 30),
},
{
name: 'checkLeakyBucketLimit',
reply: RATE_LIMIT_REPLY,
keyCount: 1,
run: async (client) => client.checkLeakyBucketLimit('rate_limit:bucket', 5, 1000, 1),
},
{
name: 'tryConsumeTokens',
reply: 2,
keyCount: 1,
run: async (client) => client.tryConsumeTokens('tokens:key', 2, 10, 1, 1000),
},
{
name: 'scheduleBulkDeletion',
reply: 1,
-5
View File
@@ -46,7 +46,6 @@ export interface PostgresQueryable {
export interface IPostgresClient extends PostgresQueryable {
connect(): Promise<void>;
shutdown(): Promise<void>;
isConnected(): boolean;
transaction<T>(fn: (client: PostgresQueryable) => Promise<T>): Promise<T>;
kvTable(): string;
}
@@ -215,10 +214,6 @@ class PostgresClient implements IPostgresClient {
await pool.end();
}
isConnected(): boolean {
return this.pool !== null && this.disconnection === null;
}
async query<T extends QueryResultRow = QueryResultRow>(
text: string,
values: Array<unknown> = [],
@@ -9,5 +9,4 @@ export interface IWorkerService<TTaskName extends string = string> {
options?: WorkerJobOptions,
): Promise<bigint>;
cancelJob(jobId: bigint): Promise<boolean>;
retryDeadLetterJob(jobId: bigint): Promise<boolean>;
}