feat(storage): add comprehensive tests for StorageManager with memory, filesystem, and custom function backends
feat(email): implement EmailSendJob class for robust email delivery with retry logic and MX record resolution feat(mail): restructure mail module exports for simplified access to core and delivery functionalities
This commit is contained in:
		@@ -0,0 +1,321 @@
 | 
			
		||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
 | 
			
		||||
import * as net from 'net';
 | 
			
		||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.ts'
 | 
			
		||||
import type { ITestServer } from '../../helpers/server.loader.ts';
 | 
			
		||||
const TEST_PORT = 2525;
 | 
			
		||||
const TEST_TIMEOUT = 30000;
 | 
			
		||||
 | 
			
		||||
let testServer: ITestServer;
 | 
			
		||||
 | 
			
		||||
tap.test('setup - start SMTP server for abrupt disconnection tests', async () => {
 | 
			
		||||
  testServer = await startTestServer({ port: TEST_PORT });
 | 
			
		||||
  await new Promise(resolve => setTimeout(resolve, 1000));
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('Abrupt Disconnection - should handle socket destruction without QUIT', async (tools) => {
 | 
			
		||||
  const done = tools.defer();
 | 
			
		||||
  
 | 
			
		||||
  try {
 | 
			
		||||
    const socket = net.createConnection({
 | 
			
		||||
      host: 'localhost',
 | 
			
		||||
      port: TEST_PORT,
 | 
			
		||||
      timeout: TEST_TIMEOUT
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    await new Promise<void>((resolve, reject) => {
 | 
			
		||||
      socket.once('connect', () => resolve());
 | 
			
		||||
      socket.once('error', reject);
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    // Get banner
 | 
			
		||||
    const banner = await new Promise<string>((resolve) => {
 | 
			
		||||
      socket.once('data', (chunk) => resolve(chunk.toString()));
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    expect(banner).toInclude('220');
 | 
			
		||||
    
 | 
			
		||||
    // Send EHLO
 | 
			
		||||
    socket.write('EHLO testhost\r\n');
 | 
			
		||||
    
 | 
			
		||||
    await new Promise<string>((resolve) => {
 | 
			
		||||
      let data = '';
 | 
			
		||||
      const handler = (chunk: Buffer) => {
 | 
			
		||||
        data += chunk.toString();
 | 
			
		||||
        if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
 | 
			
		||||
          socket.removeListener('data', handler);
 | 
			
		||||
          resolve(data);
 | 
			
		||||
        }
 | 
			
		||||
      };
 | 
			
		||||
      socket.on('data', handler);
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    // Abruptly disconnect without QUIT
 | 
			
		||||
    console.log('Destroying socket without QUIT...');
 | 
			
		||||
    socket.destroy();
 | 
			
		||||
    
 | 
			
		||||
    // Wait a moment for server to handle the disconnection
 | 
			
		||||
    await new Promise(resolve => setTimeout(resolve, 1000));
 | 
			
		||||
    
 | 
			
		||||
    // Test server recovery - try new connection
 | 
			
		||||
    console.log('Testing server recovery with new connection...');
 | 
			
		||||
    const recoverySocket = net.createConnection({
 | 
			
		||||
      host: 'localhost',
 | 
			
		||||
      port: TEST_PORT,
 | 
			
		||||
      timeout: TEST_TIMEOUT
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    const recoveryConnected = await new Promise<boolean>((resolve) => {
 | 
			
		||||
      recoverySocket.once('connect', () => resolve(true));
 | 
			
		||||
      recoverySocket.once('error', () => resolve(false));
 | 
			
		||||
      setTimeout(() => resolve(false), 5000);
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    expect(recoveryConnected).toEqual(true);
 | 
			
		||||
    
 | 
			
		||||
    if (recoveryConnected) {
 | 
			
		||||
      // Get banner from recovery connection
 | 
			
		||||
      const recoveryBanner = await new Promise<string>((resolve) => {
 | 
			
		||||
        recoverySocket.once('data', (chunk) => resolve(chunk.toString()));
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      expect(recoveryBanner).toInclude('220');
 | 
			
		||||
      console.log('Server recovered successfully, accepting new connections');
 | 
			
		||||
      
 | 
			
		||||
      // Clean up recovery connection properly
 | 
			
		||||
      recoverySocket.write('QUIT\r\n');
 | 
			
		||||
      recoverySocket.end();
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
  } finally {
 | 
			
		||||
    done.resolve();
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('Abrupt Disconnection - should handle multiple simultaneous abrupt disconnections', async (tools) => {
 | 
			
		||||
  const done = tools.defer();
 | 
			
		||||
  
 | 
			
		||||
  try {
 | 
			
		||||
    const connections = 5;
 | 
			
		||||
    const sockets: net.Socket[] = [];
 | 
			
		||||
    
 | 
			
		||||
    // Create multiple connections
 | 
			
		||||
    for (let i = 0; i < connections; i++) {
 | 
			
		||||
      const socket = net.createConnection({
 | 
			
		||||
        host: 'localhost',
 | 
			
		||||
        port: TEST_PORT,
 | 
			
		||||
        timeout: TEST_TIMEOUT
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      await new Promise<void>((resolve, reject) => {
 | 
			
		||||
        socket.once('connect', () => resolve());
 | 
			
		||||
        socket.once('error', reject);
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      // Get banner
 | 
			
		||||
      await new Promise<void>((resolve) => {
 | 
			
		||||
        socket.once('data', () => resolve());
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      sockets.push(socket);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    console.log(`Created ${connections} connections`);
 | 
			
		||||
    
 | 
			
		||||
    // Abruptly disconnect all at once
 | 
			
		||||
    console.log('Destroying all sockets simultaneously...');
 | 
			
		||||
    sockets.forEach(socket => socket.destroy());
 | 
			
		||||
    
 | 
			
		||||
    // Wait for server to handle disconnections
 | 
			
		||||
    await new Promise(resolve => setTimeout(resolve, 2000));
 | 
			
		||||
    
 | 
			
		||||
    // Test that server still accepts new connections
 | 
			
		||||
    console.log('Testing server stability after multiple abrupt disconnections...');
 | 
			
		||||
    const testSocket = net.createConnection({
 | 
			
		||||
      host: 'localhost',
 | 
			
		||||
      port: TEST_PORT,
 | 
			
		||||
      timeout: TEST_TIMEOUT
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    const stillAccepting = await new Promise<boolean>((resolve) => {
 | 
			
		||||
      testSocket.once('connect', () => resolve(true));
 | 
			
		||||
      testSocket.once('error', () => resolve(false));
 | 
			
		||||
      setTimeout(() => resolve(false), 5000);
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    expect(stillAccepting).toEqual(true);
 | 
			
		||||
    
 | 
			
		||||
    if (stillAccepting) {
 | 
			
		||||
      const banner = await new Promise<string>((resolve) => {
 | 
			
		||||
        testSocket.once('data', (chunk) => resolve(chunk.toString()));
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      expect(banner).toInclude('220');
 | 
			
		||||
      console.log('Server remained stable after multiple abrupt disconnections');
 | 
			
		||||
      
 | 
			
		||||
      testSocket.write('QUIT\r\n');
 | 
			
		||||
      testSocket.end();
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
  } finally {
 | 
			
		||||
    done.resolve();
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('Abrupt Disconnection - should handle disconnection during DATA transfer', async (tools) => {
 | 
			
		||||
  const done = tools.defer();
 | 
			
		||||
  
 | 
			
		||||
  try {
 | 
			
		||||
    const socket = net.createConnection({
 | 
			
		||||
      host: 'localhost',
 | 
			
		||||
      port: TEST_PORT,
 | 
			
		||||
      timeout: TEST_TIMEOUT
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    await new Promise<void>((resolve, reject) => {
 | 
			
		||||
      socket.once('connect', () => resolve());
 | 
			
		||||
      socket.once('error', reject);
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    // Get banner
 | 
			
		||||
    await new Promise<string>((resolve) => {
 | 
			
		||||
      socket.once('data', (chunk) => resolve(chunk.toString()));
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    // Send EHLO
 | 
			
		||||
    socket.write('EHLO testhost\r\n');
 | 
			
		||||
    await new Promise<string>((resolve) => {
 | 
			
		||||
      let data = '';
 | 
			
		||||
      const handler = (chunk: Buffer) => {
 | 
			
		||||
        data += chunk.toString();
 | 
			
		||||
        if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
 | 
			
		||||
          socket.removeListener('data', handler);
 | 
			
		||||
          resolve(data);
 | 
			
		||||
        }
 | 
			
		||||
      };
 | 
			
		||||
      socket.on('data', handler);
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    // Send MAIL FROM
 | 
			
		||||
    socket.write('MAIL FROM:<sender@example.com>\r\n');
 | 
			
		||||
    await new Promise<string>((resolve) => {
 | 
			
		||||
      socket.once('data', (chunk) => resolve(chunk.toString()));
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    // Send RCPT TO
 | 
			
		||||
    socket.write('RCPT TO:<recipient@example.com>\r\n');
 | 
			
		||||
    await new Promise<string>((resolve) => {
 | 
			
		||||
      socket.once('data', (chunk) => resolve(chunk.toString()));
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    // Start DATA
 | 
			
		||||
    socket.write('DATA\r\n');
 | 
			
		||||
    const dataResponse = await new Promise<string>((resolve) => {
 | 
			
		||||
      socket.once('data', (chunk) => resolve(chunk.toString()));
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    expect(dataResponse).toInclude('354');
 | 
			
		||||
    
 | 
			
		||||
    // Send partial email data then disconnect abruptly
 | 
			
		||||
    socket.write('From: sender@example.com\r\n');
 | 
			
		||||
    socket.write('To: recipient@example.com\r\n');
 | 
			
		||||
    socket.write('Subject: Test ');
 | 
			
		||||
    
 | 
			
		||||
    console.log('Disconnecting during DATA transfer...');
 | 
			
		||||
    socket.destroy();
 | 
			
		||||
    
 | 
			
		||||
    // Wait for server to handle disconnection
 | 
			
		||||
    await new Promise(resolve => setTimeout(resolve, 1500));
 | 
			
		||||
    
 | 
			
		||||
    // Verify server can handle new connections
 | 
			
		||||
    const newSocket = net.createConnection({
 | 
			
		||||
      host: 'localhost',
 | 
			
		||||
      port: TEST_PORT,
 | 
			
		||||
      timeout: TEST_TIMEOUT
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    const canConnect = await new Promise<boolean>((resolve) => {
 | 
			
		||||
      newSocket.once('connect', () => resolve(true));
 | 
			
		||||
      newSocket.once('error', () => resolve(false));
 | 
			
		||||
      setTimeout(() => resolve(false), 5000);
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    expect(canConnect).toEqual(true);
 | 
			
		||||
    
 | 
			
		||||
    if (canConnect) {
 | 
			
		||||
      const banner = await new Promise<string>((resolve) => {
 | 
			
		||||
        newSocket.once('data', (chunk) => resolve(chunk.toString()));
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      expect(banner).toInclude('220');
 | 
			
		||||
      console.log('Server recovered from disconnection during DATA transfer');
 | 
			
		||||
      
 | 
			
		||||
      newSocket.write('QUIT\r\n');
 | 
			
		||||
      newSocket.end();
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
  } finally {
 | 
			
		||||
    done.resolve();
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('Abrupt Disconnection - should timeout idle connections', async (tools) => {
 | 
			
		||||
  const done = tools.defer();
 | 
			
		||||
  
 | 
			
		||||
  try {
 | 
			
		||||
    const socket = net.createConnection({
 | 
			
		||||
      host: 'localhost',
 | 
			
		||||
      port: TEST_PORT,
 | 
			
		||||
      timeout: TEST_TIMEOUT
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    await new Promise<void>((resolve, reject) => {
 | 
			
		||||
      socket.once('connect', () => resolve());
 | 
			
		||||
      socket.once('error', reject);
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    // Get banner
 | 
			
		||||
    const banner = await new Promise<string>((resolve) => {
 | 
			
		||||
      socket.once('data', (chunk) => resolve(chunk.toString()));
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    expect(banner).toInclude('220');
 | 
			
		||||
    console.log('Connected, now testing idle timeout...');
 | 
			
		||||
    
 | 
			
		||||
    // Don't send any commands and wait for server to potentially timeout
 | 
			
		||||
    // Most servers have a timeout of 5-10 minutes, so we'll test shorter
 | 
			
		||||
    let disconnectedByServer = false;
 | 
			
		||||
    
 | 
			
		||||
    socket.on('close', () => {
 | 
			
		||||
      disconnectedByServer = true;
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    socket.on('end', () => {
 | 
			
		||||
      disconnectedByServer = true;
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    // Wait 10 seconds to see if server has a short idle timeout
 | 
			
		||||
    await new Promise(resolve => setTimeout(resolve, 10000));
 | 
			
		||||
    
 | 
			
		||||
    if (!disconnectedByServer) {
 | 
			
		||||
      console.log('Server maintains idle connections (no short timeout detected)');
 | 
			
		||||
      // Send QUIT to close gracefully
 | 
			
		||||
      socket.write('QUIT\r\n');
 | 
			
		||||
      socket.end();
 | 
			
		||||
    } else {
 | 
			
		||||
      console.log('Server disconnected idle connection');
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Either behavior is acceptable
 | 
			
		||||
    expect(true).toEqual(true);
 | 
			
		||||
    
 | 
			
		||||
  } finally {
 | 
			
		||||
    done.resolve();
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('cleanup - stop SMTP server', async () => {
 | 
			
		||||
  await stopTestServer(testServer);
 | 
			
		||||
  expect(true).toEqual(true);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default tap.start();
 | 
			
		||||
		Reference in New Issue
	
	Block a user