diff --git a/.eslintrc.js b/.eslintrc.js index 3c1eba73..99ac6064 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,6 +17,12 @@ module.exports = { sourceType: 'module', project: './tsconfig.json', }, + ignorePatterns: [ + '**/*.test.ts', + '**/*.spec.ts', + 'tests/**/*', + 'dist/**/*' + ], rules: { 'prettier/prettier': ['error'], }, diff --git a/db_schema/migrations/15_user_registration.sql b/db_schema/migrations/15_user_registration.sql new file mode 100644 index 00000000..c56042a1 --- /dev/null +++ b/db_schema/migrations/15_user_registration.sql @@ -0,0 +1,18 @@ +-- Migration: Add user registration tables +-- Migration ID: 15 + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + email VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_email (email) +); + +-- Note: Foreign key constraint between apiKeys.account_id and users.id +-- cannot be implemented due to cross-database limitations (apiKeys is in openpodcast_auth database) +-- The relationship is maintained programmatically in the application code + +-- Update migrations table +INSERT INTO migrations (migration_id, migration_name) VALUES (15, 'user_registration'); \ No newline at end of file diff --git a/db_schema/schema.sql b/db_schema/schema.sql index 1a84d0bb..de04f306 100644 --- a/db_schema/schema.sql +++ b/db_schema/schema.sql @@ -17,9 +17,19 @@ CREATE TABLE IF NOT EXISTS migrations ( -- ----------------------------------------- -- IMPORTANT: this is the schema version -- ID has to be incremented for each change -INSERT INTO migrations (migration_id, migration_name) VALUES (14, 'genericHoster'); +INSERT INTO migrations (migration_id, migration_name) VALUES (15, 'user_registration'); -- ----------------------------------------- +-- User registration table (introduced by migration 15) +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + email VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_email (email) +); + CREATE TABLE IF NOT EXISTS events ( account_id INTEGER NOT NULL, ev_userhash CHAR(64) AS (SHA2(CONCAT_WS("",JSON_UNQUOTE(ev_raw->"$.ip"),JSON_UNQUOTE(ev_raw->'$."user-agent"')), 256)) STORED, diff --git a/jest.config.js b/jest.config.js index 8dadb679..b08624c7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,11 +3,14 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', transform: { - '^.+\\.tsx?$': 'ts-jest', + '^.+\\.tsx?$': ['ts-jest', { + isolatedModules: true, + }], }, testMatch: [ '**/__tests__/**/*.ts', - '**/?(*.)+(spec|test).ts' + '**/?(*.)+(spec|test).ts', + '**/tests/api_e2e/**/*.js' ], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], -} +} \ No newline at end of file diff --git a/src/api/RegistrationApi.test.ts b/src/api/RegistrationApi.test.ts new file mode 100644 index 00000000..8a1b07f6 --- /dev/null +++ b/src/api/RegistrationApi.test.ts @@ -0,0 +1,216 @@ +import { RegistrationApi, RegisterRequest } from './RegistrationApi' +import { UserRepository, User } from '../db/UserRepository' +import { AccountKeyRepository } from '../db/AccountKeyRepository' + +jest.mock('../db/UserRepository') +jest.mock('../db/AccountKeyRepository') + +const MockedUserRepository = UserRepository as jest.MockedClass +const MockedAccountKeyRepository = AccountKeyRepository as jest.MockedClass + +describe('RegistrationApi', () => { + let registrationApi: RegistrationApi + let mockUserRepo: jest.Mocked + let mockAccountKeyRepo: jest.Mocked + + beforeEach(() => { + mockUserRepo = new MockedUserRepository({} as any) as jest.Mocked + mockAccountKeyRepo = new MockedAccountKeyRepository({} as any) as jest.Mocked + registrationApi = new RegistrationApi(mockUserRepo, mockAccountKeyRepo) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('register', () => { + const validRequest: RegisterRequest = { + name: 'John Doe', + email: 'john@example.com' + } + + it('should register a new user successfully', async () => { + const mockUser: User = { + id: 1, + name: 'John Doe', + email: 'john@example.com', + created_at: '2023-01-01T00:00:00.000Z', + updated_at: '2023-01-01T00:00:00.000Z' + } + + mockUserRepo.getUserByEmail.mockResolvedValue(null) + mockUserRepo.createUser.mockResolvedValue(mockUser) + mockAccountKeyRepo.generateApiKey.mockResolvedValue('op_1234567890abcdef1234567890abcdef') + + const result = await registrationApi.register(validRequest) + + expect(result.statusCode).toBe(201) + expect(result.response.success).toBe(true) + expect(result.response.data).toEqual({ + userId: 1, + apiKey: 'op_1234567890abcdef1234567890abcdef', + email: 'john@example.com', + name: 'John Doe' + }) + + expect(mockUserRepo.getUserByEmail).toHaveBeenCalledWith('john@example.com') + expect(mockUserRepo.createUser).toHaveBeenCalledWith('John Doe', 'john@example.com') + expect(mockAccountKeyRepo.generateApiKey).toHaveBeenCalledWith(1) + }) + + it('should return existing user data when email already exists', async () => { + const existingUser: User = { + id: 2, + name: 'Jane Doe', + email: 'john@example.com', + created_at: '2023-01-01T00:00:00.000Z', + updated_at: '2023-01-01T00:00:00.000Z' + } + + mockUserRepo.getUserByEmail.mockResolvedValue(existingUser) + mockAccountKeyRepo.getApiKeyHashByAccountId.mockResolvedValue('hash123') + mockAccountKeyRepo.generateApiKey.mockResolvedValue('op_existingkey1234567890abcdef123456') + + const result = await registrationApi.register(validRequest) + + expect(result.statusCode).toBe(409) + expect(result.response.success).toBe(false) + expect(result.response.error).toBe('Email already registered') + expect(result.response.data).toEqual({ + userId: 2, + apiKey: 'op_existingkey1234567890abcdef123456', + email: 'john@example.com', + name: 'Jane Doe' + }) + + expect(mockUserRepo.createUser).not.toHaveBeenCalled() + }) + + it('should return validation error for missing name', async () => { + const invalidRequest = { + name: '', + email: 'john@example.com' + } + + const result = await registrationApi.register(invalidRequest) + + expect(result.statusCode).toBe(400) + expect(result.response.success).toBe(false) + expect(result.response.error).toBe('Validation failed') + expect(result.response.details).toContain('Name is required') + }) + + it('should return validation error for missing email', async () => { + const invalidRequest = { + name: 'John Doe', + email: '' + } + + const result = await registrationApi.register(invalidRequest) + + expect(result.statusCode).toBe(400) + expect(result.response.success).toBe(false) + expect(result.response.error).toBe('Validation failed') + expect(result.response.details).toContain('Email is required') + }) + + it('should return validation error for invalid email format', async () => { + const invalidRequest = { + name: 'John Doe', + email: 'invalid-email' + } + + const result = await registrationApi.register(invalidRequest) + + expect(result.statusCode).toBe(400) + expect(result.response.success).toBe(false) + expect(result.response.error).toBe('Validation failed') + expect(result.response.details).toContain('Email must be valid') + }) + + it('should return validation error for name too short', async () => { + const invalidRequest = { + name: 'A', + email: 'john@example.com' + } + + const result = await registrationApi.register(invalidRequest) + + expect(result.statusCode).toBe(400) + expect(result.response.success).toBe(false) + expect(result.response.error).toBe('Validation failed') + expect(result.response.details).toContain('Name must be between 2 and 100 characters') + }) + + it('should return validation error for name too long', async () => { + const invalidRequest = { + name: 'A'.repeat(101), + email: 'john@example.com' + } + + const result = await registrationApi.register(invalidRequest) + + expect(result.statusCode).toBe(400) + expect(result.response.success).toBe(false) + expect(result.response.error).toBe('Validation failed') + expect(result.response.details).toContain('Name must be between 2 and 100 characters') + }) + + it('should normalize email to lowercase', async () => { + const requestWithUppercaseEmail = { + name: 'John Doe', + email: 'JOHN@EXAMPLE.COM' + } + + const mockUser: User = { + id: 1, + name: 'John Doe', + email: 'john@example.com', + created_at: '2023-01-01T00:00:00.000Z', + updated_at: '2023-01-01T00:00:00.000Z' + } + + mockUserRepo.getUserByEmail.mockResolvedValue(null) + mockUserRepo.createUser.mockResolvedValue(mockUser) + mockAccountKeyRepo.generateApiKey.mockResolvedValue('op_1234567890abcdef1234567890abcdef') + + await registrationApi.register(requestWithUppercaseEmail) + + expect(mockUserRepo.getUserByEmail).toHaveBeenCalledWith('john@example.com') + expect(mockUserRepo.createUser).toHaveBeenCalledWith('John Doe', 'john@example.com') + }) + + it('should trim whitespace from name', async () => { + const requestWithWhitespace = { + name: ' John Doe ', + email: 'john@example.com' + } + + const mockUser: User = { + id: 1, + name: 'John Doe', + email: 'john@example.com', + created_at: '2023-01-01T00:00:00.000Z', + updated_at: '2023-01-01T00:00:00.000Z' + } + + mockUserRepo.getUserByEmail.mockResolvedValue(null) + mockUserRepo.createUser.mockResolvedValue(mockUser) + mockAccountKeyRepo.generateApiKey.mockResolvedValue('op_1234567890abcdef1234567890abcdef') + + await registrationApi.register(requestWithWhitespace) + + expect(mockUserRepo.createUser).toHaveBeenCalledWith('John Doe', 'john@example.com') + }) + + it('should handle database errors gracefully', async () => { + mockUserRepo.getUserByEmail.mockRejectedValue(new Error('Database error')) + + const result = await registrationApi.register(validRequest) + + expect(result.statusCode).toBe(500) + expect(result.response.success).toBe(false) + expect(result.response.error).toBe('Internal server error') + }) + }) +}) \ No newline at end of file diff --git a/src/api/RegistrationApi.ts b/src/api/RegistrationApi.ts new file mode 100644 index 00000000..7245a8b8 --- /dev/null +++ b/src/api/RegistrationApi.ts @@ -0,0 +1,166 @@ +import { UserRepository } from '../db/UserRepository' +import { AccountKeyRepository } from '../db/AccountKeyRepository' + +export interface RegisterRequest { + name: string + email: string +} + +export interface RegisterResponse { + success: boolean + data?: { + userId: number + apiKey: string + email: string + name: string + } + error?: string + details?: string[] +} + +export class RegistrationApi { + private userRepo: UserRepository + private accountKeyRepo: AccountKeyRepository + + constructor( + userRepo: UserRepository, + accountKeyRepo: AccountKeyRepository + ) { + this.userRepo = userRepo + this.accountKeyRepo = accountKeyRepo + } + + async register( + data: RegisterRequest + ): Promise<{ response: RegisterResponse; statusCode: number }> { + const validation = this.validateInput(data) + if (!validation.isValid) { + return { + response: { + success: false, + error: 'Validation failed', + details: validation.errors, + }, + statusCode: 400, + } + } + + const { name, email } = data + const normalizedEmail = email.toLowerCase().trim() + const trimmedName = name.trim() + + try { + const existingUser = await this.userRepo.getUserByEmail( + normalizedEmail + ) + + if (existingUser) { + let apiKey = await this.getExistingApiKey(existingUser.id) + if (!apiKey) { + apiKey = await this.accountKeyRepo.generateApiKey( + existingUser.id + ) + } + + return { + response: { + success: false, + error: 'Email already registered', + data: { + userId: existingUser.id, + apiKey, + email: existingUser.email, + name: existingUser.name, + }, + }, + statusCode: 409, + } + } + + const newUser = await this.userRepo.createUser( + trimmedName, + normalizedEmail + ) + const apiKey = await this.accountKeyRepo.generateApiKey(newUser.id) + + return { + response: { + success: true, + data: { + userId: newUser.id, + apiKey, + email: newUser.email, + name: newUser.name, + }, + }, + statusCode: 201, + } + } catch (error) { + console.error('Registration error:', error) + return { + response: { + success: false, + error: 'Internal server error', + }, + statusCode: 500, + } + } + } + + private validateInput(data: RegisterRequest): { + isValid: boolean + errors: string[] + } { + const errors: string[] = [] + + if ( + !data.name || + typeof data.name !== 'string' || + data.name.trim().length === 0 + ) { + errors.push('Name is required') + } else if ( + data.name.trim().length < 2 || + data.name.trim().length > 100 + ) { + errors.push('Name must be between 2 and 100 characters') + } + + if ( + !data.email || + typeof data.email !== 'string' || + data.email.trim().length === 0 + ) { + errors.push('Email is required') + } else if (!this.isValidEmail(data.email.trim())) { + errors.push('Email must be valid') + } + + return { + isValid: errors.length === 0, + errors, + } + } + + private isValidEmail(email: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(email) + } + + private async getExistingApiKey(userId: number): Promise { + const keyHash = await this.accountKeyRepo.getApiKeyHashByAccountId( + userId + ) + if (!keyHash) { + return null + } + + return this.generateNewApiKeyForExistingUser(userId) + } + + private async generateNewApiKeyForExistingUser( + userId: number + ): Promise { + return await this.accountKeyRepo.generateApiKey(userId) + } +} diff --git a/src/db/AccountKeyRepository.ts b/src/db/AccountKeyRepository.ts index 02560903..59d9a647 100644 --- a/src/db/AccountKeyRepository.ts +++ b/src/db/AccountKeyRepository.ts @@ -1,6 +1,5 @@ -import { Pool, RowDataPacket } from 'mysql2/promise' +import { Pool, RowDataPacket, ResultSetHeader } from 'mysql2/promise' import crypto from 'crypto' -import { i } from 'mathjs' class AccountKeyRepository { private pool: Pool @@ -35,6 +34,55 @@ class AccountKeyRepository { .map((row) => row.account_id as number) } + /** + * Generate a new API key and store it for the given account ID + * + * @param accountId The account ID to associate with the API key + * @returns The generated API key (not hashed) + */ + async generateApiKey(accountId: number): Promise { + const apiKey = this.generateRandomApiKey() + const hashedKey = this.hashKey(apiKey) + + await this.pool.execute( + 'INSERT INTO apiKeys (key_hash, account_id) VALUES (?, ?)', + [hashedKey, accountId] + ) + + return apiKey + } + + /** + * Get the first API key hash for a given account ID + * Note: This returns the hash, not the actual key (which cannot be retrieved) + * + * @param accountId The account ID to lookup + * @returns The API key hash if found, null otherwise + */ + async getApiKeyHashByAccountId(accountId: number): Promise { + const [rows] = await this.pool.execute( + 'SELECT key_hash FROM apiKeys WHERE account_id = ? LIMIT 1', + [accountId] + ) + + if (!Array.isArray(rows) || rows.length === 0) { + return null + } + + return rows[0].key_hash as string + } + + /** + * Generate a random API key in the format: op_<32 hex characters> + * + * @returns A random API key string + */ + private generateRandomApiKey(): string { + const randomBytes = crypto.randomBytes(16) + const hexString = randomBytes.toString('hex') + return `op_${hexString}` + } + /** * Hash an API key for secure storage * diff --git a/src/db/UserRepository.test.ts b/src/db/UserRepository.test.ts new file mode 100644 index 00000000..3fa1f0d0 --- /dev/null +++ b/src/db/UserRepository.test.ts @@ -0,0 +1,180 @@ +import { UserRepository, User } from './UserRepository' +import { Pool, RowDataPacket, ResultSetHeader, FieldPacket } from 'mysql2/promise' + +jest.mock('mysql2/promise') + +describe('UserRepository', () => { + let userRepo: UserRepository + let mockPool: jest.Mocked + + beforeEach(() => { + mockPool = { + execute: jest.fn() + } as any + + userRepo = new UserRepository(mockPool) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('getUserByEmail', () => { + it('should return user when found', async () => { + const mockUser = { + id: 1, + email: 'john@example.com', + name: 'John Doe', + created_at: '2023-01-01T00:00:00.000Z', + updated_at: '2023-01-01T00:00:00.000Z' + } + + const mockRows: RowDataPacket[] = [mockUser as RowDataPacket] + const mockFields: FieldPacket[] = [] + mockPool.execute.mockResolvedValue([mockRows, mockFields]) + + const result = await userRepo.getUserByEmail('john@example.com') + + expect(result).toEqual(mockUser) + expect(mockPool.execute).toHaveBeenCalledWith( + 'SELECT id, email, name, created_at, updated_at FROM users WHERE email = ?', + ['john@example.com'] + ) + }) + + it('should return null when user not found', async () => { + const mockRows: RowDataPacket[] = [] + const mockFields: FieldPacket[] = [] + mockPool.execute.mockResolvedValue([mockRows, mockFields]) + + const result = await userRepo.getUserByEmail('notfound@example.com') + + expect(result).toBeNull() + }) + + it('should normalize email to lowercase', async () => { + const mockRows: RowDataPacket[] = [] + const mockFields: FieldPacket[] = [] + mockPool.execute.mockResolvedValue([mockRows, mockFields]) + + await userRepo.getUserByEmail('JOHN@EXAMPLE.COM') + + expect(mockPool.execute).toHaveBeenCalledWith( + 'SELECT id, email, name, created_at, updated_at FROM users WHERE email = ?', + ['john@example.com'] + ) + }) + }) + + describe('createUser', () => { + it('should create user successfully', async () => { + const mockResult = { + insertId: 1, + affectedRows: 1, + changedRows: 1, + fieldCount: 0, + info: '', + serverStatus: 0, + warningStatus: 0, + constructor: { name: 'ResultSetHeader' } + } as ResultSetHeader + + const mockUser = { + id: 1, + email: 'john@example.com', + name: 'John Doe', + created_at: '2023-01-01T00:00:00.000Z', + updated_at: '2023-01-01T00:00:00.000Z' + } + + const mockFields: FieldPacket[] = [] + + mockPool.execute + .mockResolvedValueOnce([mockResult, mockFields]) // INSERT + .mockResolvedValueOnce([[mockUser as RowDataPacket], mockFields]) // SELECT + + const result = await userRepo.createUser('John Doe', 'john@example.com') + + expect(result).toEqual(mockUser) + expect(mockPool.execute).toHaveBeenCalledWith( + 'INSERT INTO users (name, email) VALUES (?, ?)', + ['John Doe', 'john@example.com'] + ) + }) + + it('should normalize email to lowercase and trim name', async () => { + const mockResult = { + insertId: 1, + affectedRows: 1, + changedRows: 1, + fieldCount: 0, + info: '', + serverStatus: 0, + warningStatus: 0, + constructor: { name: 'ResultSetHeader' } + } as ResultSetHeader + + const mockUser = { + id: 1, + email: 'john@example.com', + name: 'John Doe', + created_at: '2023-01-01T00:00:00.000Z', + updated_at: '2023-01-01T00:00:00.000Z' + } + + const mockFields: FieldPacket[] = [] + + mockPool.execute + .mockResolvedValueOnce([mockResult, mockFields]) + .mockResolvedValueOnce([[mockUser as RowDataPacket], mockFields]) + + await userRepo.createUser(' John Doe ', 'JOHN@EXAMPLE.COM') + + expect(mockPool.execute).toHaveBeenCalledWith( + 'INSERT INTO users (name, email) VALUES (?, ?)', + ['John Doe', 'john@example.com'] + ) + }) + + it('should throw error when insertId is not available', async () => { + const mockResult = { + insertId: 0, + affectedRows: 1, + changedRows: 1, + fieldCount: 0, + info: '', + serverStatus: 0, + warningStatus: 0, + constructor: { name: 'ResultSetHeader' } + } as ResultSetHeader + + const mockFields: FieldPacket[] = [] + mockPool.execute.mockResolvedValue([mockResult, mockFields]) + + await expect(userRepo.createUser('John Doe', 'john@example.com')) + .rejects.toThrow('Failed to create user') + }) + + it('should throw error when created user cannot be retrieved', async () => { + const mockResult = { + insertId: 1, + affectedRows: 1, + changedRows: 1, + fieldCount: 0, + info: '', + serverStatus: 0, + warningStatus: 0, + constructor: { name: 'ResultSetHeader' } + } as ResultSetHeader + + const mockFields: FieldPacket[] = [] + + mockPool.execute + .mockResolvedValueOnce([mockResult, mockFields]) + .mockResolvedValueOnce([[], mockFields]) + + await expect(userRepo.createUser('John Doe', 'john@example.com')) + .rejects.toThrow('Failed to retrieve created user') + }) + }) +}) \ No newline at end of file diff --git a/src/db/UserRepository.ts b/src/db/UserRepository.ts new file mode 100644 index 00000000..86279c42 --- /dev/null +++ b/src/db/UserRepository.ts @@ -0,0 +1,61 @@ +import { Pool, RowDataPacket, ResultSetHeader } from 'mysql2/promise' + +export interface User { + id: number + email: string + name: string + created_at: string + updated_at: string +} + +export class UserRepository { + private pool: Pool + + constructor(pool: Pool) { + this.pool = pool + } + + async getUserByEmail(email: string): Promise { + const [rows] = await this.pool.execute( + 'SELECT id, email, name, created_at, updated_at FROM users WHERE email = ?', + [email.toLowerCase()] + ) + + if (!Array.isArray(rows) || rows.length === 0) { + return null + } + + return rows[0] as User + } + + async createUser(name: string, email: string): Promise { + const [result] = await this.pool.execute( + 'INSERT INTO users (name, email) VALUES (?, ?)', + [name.trim(), email.toLowerCase()] + ) + + if (!result.insertId) { + throw new Error('Failed to create user') + } + + const user = await this.getUserById(result.insertId) + if (!user) { + throw new Error('Failed to retrieve created user') + } + + return user + } + + private async getUserById(id: number): Promise { + const [rows] = await this.pool.execute( + 'SELECT id, email, name, created_at, updated_at FROM users WHERE id = ?', + [id] + ) + + if (!Array.isArray(rows) || rows.length === 0) { + return null + } + + return rows[0] as User + } +} diff --git a/src/index.ts b/src/index.ts index 1b302f45..9fbf49cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,8 @@ import { AnalyticsRepository } from './db/AnalyticsRepository' import { AnalyticsApi } from './api/AnalyticsApi' import { formatDate, nowString } from './utils/dateHelpers' import { AccountKeyRepository } from './db/AccountKeyRepository' +import { UserRepository } from './db/UserRepository' +import { RegistrationApi } from './api/RegistrationApi' const config = new Config() @@ -92,6 +94,10 @@ const statusApi = new StatusApi(statusRepo) // Initialize the account key repository const accountKeyRepo = new AccountKeyRepository(authPool) +// Initialize the user repository and registration API +const userRepo = new UserRepository(pool) +const registrationApi = new RegistrationApi(userRepo, accountKeyRepo) + const supportedGenericHosters = { podigee: 1, } @@ -117,6 +123,7 @@ const publicEndpoints = [ '^/status', '^/feedback/*', '^/comments/*', + '^/register$', ] const authController = new AuthController(accountKeyRepo) @@ -413,6 +420,36 @@ app.post( } ) +app.post( + '/register', + body('name') + .trim() + .isLength({ min: 2, max: 100 }) + .withMessage('Name must be between 2 and 100 characters'), + body('email').normalizeEmail().isEmail().withMessage('Email must be valid'), + async (req: Request, res: Response, next: NextFunction) => { + const errors = validationResult(req) + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + error: 'Validation failed', + details: errors.array().map((err) => err.msg), + }) + } + + try { + const { response, statusCode } = await registrationApi.register({ + name: req.body.name, + email: req.body.email, + }) + + res.status(statusCode).json(response) + } catch (err) { + next(err) + } + } +) + // endpoint for events coming from the proxy app.post('/events', (async ( req: Request, diff --git a/tests/api_e2e/registration.test.js b/tests/api_e2e/registration.test.js new file mode 100644 index 00000000..cf6b2bf9 --- /dev/null +++ b/tests/api_e2e/registration.test.js @@ -0,0 +1,223 @@ +const request = require('supertest') +const baseURL = 'http://localhost:8080' + +describe('User Registration API', () => { + const validUserData = { + name: 'John Doe', + email: 'john.doe@example.com' + } + + describe('POST /register - Successful Registration', () => { + it('should register a new user successfully', async () => { + const uniqueEmail = `test-${Date.now()}@example.com` + const userData = { + name: 'Test User', + email: uniqueEmail + } + + const response = await request(baseURL) + .post('/register') + .send(userData) + + expect(response.statusCode).toBe(201) + expect(response.body).toMatchObject({ + success: true, + data: { + userId: expect.any(Number), + apiKey: expect.stringMatching(/^op_[a-f0-9]{32}$/), + email: uniqueEmail, + name: 'Test User' + } + }) + }) + }) + + describe('POST /register - Duplicate Email', () => { + it('should return existing user data when email already exists', async () => { + const existingEmail = `existing-${Date.now()}@example.com` + const userData = { + name: 'First User', + email: existingEmail + } + + // First registration + const firstResponse = await request(baseURL) + .post('/register') + .send(userData) + + expect(firstResponse.statusCode).toBe(201) + + // Second registration with same email + const secondResponse = await request(baseURL) + .post('/register') + .send({ + name: 'Second User', + email: existingEmail + }) + + expect(secondResponse.statusCode).toBe(409) + expect(secondResponse.body).toMatchObject({ + success: false, + error: 'Email already registered', + data: { + userId: expect.any(Number), + apiKey: expect.stringMatching(/^op_[a-f0-9]{32}$/), + email: existingEmail, + name: 'First User' // Should maintain original name + } + }) + }) + }) + + describe('POST /register - Validation Errors', () => { + it('should return 400 for missing name', async () => { + const response = await request(baseURL) + .post('/register') + .send({ + email: 'test@example.com' + }) + + expect(response.statusCode).toBe(400) + expect(response.body).toMatchObject({ + success: false, + error: 'Validation failed', + details: expect.arrayContaining([ + expect.stringContaining('Name') + ]) + }) + }) + + it('should return 400 for missing email', async () => { + const response = await request(baseURL) + .post('/register') + .send({ + name: 'Test User' + }) + + expect(response.statusCode).toBe(400) + expect(response.body).toMatchObject({ + success: false, + error: 'Validation failed', + details: expect.arrayContaining([ + expect.stringContaining('Email') + ]) + }) + }) + + it('should return 400 for invalid email format', async () => { + const response = await request(baseURL) + .post('/register') + .send({ + name: 'Test User', + email: 'invalid-email' + }) + + expect(response.statusCode).toBe(400) + expect(response.body).toMatchObject({ + success: false, + error: 'Validation failed', + details: expect.arrayContaining([ + expect.stringContaining('Email') + ]) + }) + }) + + it('should return 400 for name too short', async () => { + const response = await request(baseURL) + .post('/register') + .send({ + name: 'A', + email: 'test@example.com' + }) + + expect(response.statusCode).toBe(400) + expect(response.body).toMatchObject({ + success: false, + error: 'Validation failed', + details: expect.arrayContaining([ + expect.stringContaining('Name') + ]) + }) + }) + + it('should return 400 for name too long', async () => { + const longName = 'A'.repeat(101) + const response = await request(baseURL) + .post('/register') + .send({ + name: longName, + email: 'test@example.com' + }) + + expect(response.statusCode).toBe(400) + expect(response.body).toMatchObject({ + success: false, + error: 'Validation failed', + details: expect.arrayContaining([ + expect.stringContaining('Name') + ]) + }) + }) + + it('should return 400 for empty JSON body', async () => { + const response = await request(baseURL) + .post('/register') + .send({}) + + expect(response.statusCode).toBe(400) + expect(response.body).toMatchObject({ + success: false, + error: 'Validation failed' + }) + }) + }) + + describe('POST /register - Data Normalization', () => { + it('should normalize email to lowercase', async () => { + const uniqueEmail = `TEST-${Date.now()}@EXAMPLE.COM` + const userData = { + name: 'Test User', + email: uniqueEmail + } + + const response = await request(baseURL) + .post('/register') + .send(userData) + + expect(response.statusCode).toBe(201) + expect(response.body.data.email).toBe(uniqueEmail.toLowerCase()) + }) + + it('should trim whitespace from name', async () => { + const uniqueEmail = `test-${Date.now()}@example.com` + const userData = { + name: ' Test User ', + email: uniqueEmail + } + + const response = await request(baseURL) + .post('/register') + .send(userData) + + expect(response.statusCode).toBe(201) + expect(response.body.data.name).toBe('Test User') + }) + }) + + describe('POST /register - API Key Format', () => { + it('should generate API key with correct format', async () => { + const uniqueEmail = `test-${Date.now()}@example.com` + const userData = { + name: 'Test User', + email: uniqueEmail + } + + const response = await request(baseURL) + .post('/register') + .send(userData) + + expect(response.statusCode).toBe(201) + expect(response.body.data.apiKey).toMatch(/^op_[a-f0-9]{32}$/) + }) + }) +}) \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 0ce95b84..6cb9242c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -91,5 +91,10 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ - } + }, + "exclude": [ + "**/*.test.ts", + "**/*.spec.ts", + "tests/**/*" + ] } \ No newline at end of file