mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
chore(repo): remove dead code and useless tests (#3375)
This commit is contained in:
1 parent
7e177a1c90
commit
1cd62eb661
1391 files changed
+783
-120415
No files matched your search
-2
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in new issue
Block a user