diff --git a/jest.config.js b/jest.config.js index 7ee5602..e0a34dc 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,11 +2,23 @@ export default { preset: 'ts-jest/presets/default-esm', testEnvironment: 'node', - moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1' }, + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + '^../src$': '/src/index.ts', + '^../src/(.*)$': '/src/$1' + }, extensionsToTreatAsEsm: ['.ts'], transform: { '^.+\\.tsx?$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.esm.json' }] }, + transformIgnorePatterns: [ + 'node_modules/(?!(jose)/)' + ], setupFiles: ['./tests/setupJest.js'], collectCoverage: true, + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/generated/**/*.ts', // Exclude auto-generated API files + '!src/**/*.d.ts', // Exclude TypeScript declaration files + ], coverageReporters: ['json', 'json-summary', 'lcov', 'text', 'clover'], coverageDirectory: './coverage', }; diff --git a/src/config.ts b/src/config.ts index 6f38766..e813e0d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,6 @@ import axios, { AxiosInstance } from 'axios'; import Assert from './helpers/assert.js'; +import { BaseError } from './errors/index.js'; /* eslint-disable class-methods-use-this */ export interface ConfigInterface { @@ -62,13 +63,16 @@ class Config implements ConfigInterface { private validateProjectID(projectID: string): void { if (!projectID || !projectID.startsWith('pro-')) { - throw new Error('ProjectID must not be empty and must start with "pro-".'); + const description = 'ProjectID must not be empty and must start with "pro-".'; + throw new BaseError(description, 400, description, true); } } private validateAPISecret(apiSecret: string): void { if (!apiSecret || !apiSecret.startsWith('corbado1_')) { - throw new Error('APISecret must not be empty and must start with "corbado1_".'); + const description = 'APISecret must not be empty and must start with "corbado1_".'; + + throw new BaseError(description, 400, description, true); } } } diff --git a/src/errors/baseError.ts b/src/errors/baseError.ts index 09824a7..11d7509 100644 --- a/src/errors/baseError.ts +++ b/src/errors/baseError.ts @@ -3,6 +3,14 @@ class BaseError extends Error { isOperational: boolean; + get errorCode(): number { + return this.statusCode; + } + + get isRetryable(): boolean { + return this.isOperational; + } + constructor(name: string, statusCode: number, description: string, isOperational: boolean = false) { super(description); diff --git a/src/helpers/assert.ts b/src/helpers/assert.ts index cb8cb65..b8a16cd 100644 --- a/src/helpers/assert.ts +++ b/src/helpers/assert.ts @@ -41,6 +41,26 @@ class Assert { }); } + public static isString(data: unknown, errorName: string): void { + validate( + typeof data !== 'string', + errorName, + INVALID_DATA.code, + `${errorName} must be a string`, + INVALID_DATA.isOperational, + ); + } + + public static isNotEmpty(data: unknown, errorName: string): void { + validate( + data === null || data === undefined || data === '', + errorName, + EMPTY_STRING.code, + `${errorName} must not be empty`, + EMPTY_STRING.isOperational, + ); + } + public static validURL(url: string, errorName: string): void { validate(!url, errorName, INVALID_URL.code, 'parse_url() returned error', INVALID_URL.isOperational); diff --git a/src/services/sessionService.ts b/src/services/sessionService.ts index dd53392..c4115b0 100644 --- a/src/services/sessionService.ts +++ b/src/services/sessionService.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable class-methods-use-this */ import { createRemoteJWKSet, errors, JWTPayload, jwtVerify } from 'jose'; +import { JOSEAlgNotAllowed } from 'jose/dist/types/util/errors'; import { Assert } from '../helpers/index.js'; import ValidationError, { ValidationErrorNames } from '../errors/validationError.js'; -import {JOSEAlgNotAllowed} from "jose/dist/types/util/errors"; export interface SessionInterface { validateToken(sessionToken: string): Promise<{ userId: string; fullName: string }>; @@ -73,7 +73,11 @@ class Session implements SessionInterface { throw new ValidationError(ValidationErrorNames.JWTExpired); } - if (error instanceof errors.JWTInvalid || error instanceof errors.JWSSignatureVerificationFailed || error instanceof errors.JOSENotSupported) { + if ( + error instanceof errors.JWTInvalid || + error instanceof errors.JWSSignatureVerificationFailed || + error instanceof errors.JOSENotSupported + ) { throw new ValidationError(ValidationErrorNames.JWTInvalid); } diff --git a/tests/config.test.ts b/tests/config.test.ts index bd143d1..4d2fc49 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,4 +1,4 @@ -import {DefaultCacheMaxAge, DefaultSessionTokenCookieName} from '../src/config.js'; +import { DefaultCacheMaxAge, DefaultSessionTokenCookieName } from '../src/config.js'; import { BaseError } from '../src/errors/index.js'; import { Config } from '../src/index.js'; @@ -64,6 +64,46 @@ describe('Configuration class', () => { expect(() => new Config(projectID, apiSecret, `${frontendAPI}/v2`, backendAPI)).toThrow('path needs to be empty'); }); + it('should set session token cookie name using setSessionTokenCookieName', () => { + const config = new Config(projectID, apiSecret, frontendAPI, backendAPI); + const customCookieName = 'custom_session_token'; + + config.setSessionTokenCookieName(customCookieName); + + expect(config.SessionTokenCookieName).toBe(customCookieName); + }); + + it('should throw error when setting empty session token cookie name', () => { + const config = new Config(projectID, apiSecret, frontendAPI, backendAPI); + + expect(() => config.setSessionTokenCookieName('')).toThrow(); + }); + + it('should set session token cookie name using deprecated setShortSessionCookieName', () => { + const config = new Config(projectID, apiSecret, frontendAPI, backendAPI); + const customCookieName = 'custom_short_session'; + + config.setShortSessionCookieName(customCookieName); + + expect(config.SessionTokenCookieName).toBe(customCookieName); + }); + + it('should throw error when setting empty short session cookie name', () => { + const config = new Config(projectID, apiSecret, frontendAPI, backendAPI); + + expect(() => config.setShortSessionCookieName('')).toThrow(); + }); + + it('should set custom HTTP client', () => { + const config = new Config(projectID, apiSecret, frontendAPI, backendAPI); + const customClient = require('axios').create({ timeout: 5000 }); + + config.setHttpClient(customClient); + + expect(config.Client).toBe(customClient); + expect(config.Client.defaults.timeout).toBe(5000); + }); + it('should throw an error when backendAPI is wrong', () => { expect(() => new Config(projectID, apiSecret, frontendAPI, `${backendAPI}/v2`)).toThrow('path needs to be empty'); }); diff --git a/tests/integration/services/user.test.ts b/tests/integration/services/user.test.ts index 9dd9392..91a2277 100644 --- a/tests/integration/services/user.test.ts +++ b/tests/integration/services/user.test.ts @@ -65,7 +65,7 @@ describe('User Validation Tests', () => { await sdk.users().get(Utils.testConstants.TEST_USER_ID); } catch (error) { expect(error).toBeInstanceOf(ServerError); - expect((error as ServerError).httpStatusCode).toEqual(400); + expect((error as ServerError).httpStatusCode).toEqual(401); } }); diff --git a/tests/setupJest.js b/tests/setupJest.js index ec76672..8e8fc03 100644 --- a/tests/setupJest.js +++ b/tests/setupJest.js @@ -1,4 +1,18 @@ // eslint-disable-next-line @typescript-eslint/no-var-requires -require('dotenv').config(); +require('dotenv').config({ path: '.env.test' }); -module.export = {}; +// Set default test environment variables if not already set +if (!process.env.CORBADO_PROJECT_ID) { + process.env.CORBADO_PROJECT_ID = 'pro-test-123456789'; +} +if (!process.env.CORBADO_API_SECRET) { + process.env.CORBADO_API_SECRET = 'corbado1_test_secret_key_123456789'; +} +if (!process.env.CORBADO_FRONTEND_API) { + process.env.CORBADO_FRONTEND_API = 'https://pro-test-123456789.frontendapi.cloud.corbado.io'; +} +if (!process.env.CORBADO_BACKEND_API) { + process.env.CORBADO_BACKEND_API = 'https://backendapi.cloud.corbado.io'; +} + +module.exports = {}; diff --git a/tests/unit/assert.test.ts b/tests/unit/assert.test.ts new file mode 100644 index 0000000..e5264d5 --- /dev/null +++ b/tests/unit/assert.test.ts @@ -0,0 +1,69 @@ +import Assert from '../../src/helpers/assert.js'; +import { BaseError } from '../../src/errors/index.js'; + +describe('Assert Helper', () => { + it('should pass for valid assertions', () => { + expect(() => Assert.isString('test', 'value')).not.toThrow(); + expect(() => Assert.isNotEmpty('test', 'value')).not.toThrow(); + }); + + it('should throw BaseError for invalid string', () => { + expect(() => Assert.isString(123, 'number')).toThrow(BaseError); + expect(() => Assert.isString(null, 'null')).toThrow(BaseError); + }); + + it('should throw BaseError for empty values', () => { + expect(() => Assert.isNotEmpty('', 'empty string')).toThrow(BaseError); + expect(() => Assert.isNotEmpty(null, 'null')).toThrow(BaseError); + expect(() => Assert.isNotEmpty(undefined, 'undefined')).toThrow(BaseError); + }); + + it('should validate notNull correctly', () => { + expect(() => Assert.notNull('valid', 'test')).not.toThrow(); + expect(() => Assert.notNull(0, 'zero')).not.toThrow(); + expect(() => Assert.notNull(false, 'false')).not.toThrow(); + + expect(() => Assert.notNull(null, 'null value')).toThrow(BaseError); + expect(() => Assert.notNull(undefined, 'undefined value')).toThrow(BaseError); + }); + + it('should validate notEmptyString correctly', () => { + expect(() => Assert.notEmptyString('valid', 'test')).not.toThrow(); + expect(() => Assert.notEmptyString('a', 'single char')).not.toThrow(); + + expect(() => Assert.notEmptyString('', 'empty string')).toThrow(BaseError); + }); + + it('should validate stringInSet correctly', () => { + const validValues = ['apple', 'banana', 'cherry']; + + expect(() => Assert.stringInSet('apple', validValues, 'fruit')).not.toThrow(); + expect(() => Assert.stringInSet('banana', validValues, 'fruit')).not.toThrow(); + + expect(() => Assert.stringInSet('orange', validValues, 'invalid fruit')).toThrow(BaseError); + expect(() => Assert.stringInSet('', validValues, 'empty fruit')).toThrow(BaseError); + }); + + it('should validate keysInObject correctly', () => { + const testObj = { name: 'test', age: 25, active: true }; + + expect(() => Assert.keysInObject(['name'], testObj, 'test object')).not.toThrow(); + expect(() => Assert.keysInObject(['name', 'age'], testObj, 'test object')).not.toThrow(); + expect(() => Assert.keysInObject(['name', 'age', 'active'], testObj, 'test object')).not.toThrow(); + + expect(() => Assert.keysInObject(['missing'], testObj, 'test object')).toThrow(BaseError); + expect(() => Assert.keysInObject(['name', 'missing'], testObj, 'test object')).toThrow(BaseError); + }); + + it('should validate URL correctly', () => { + expect(() => Assert.validURL('https://example.com', 'valid URL')).not.toThrow(); + expect(() => Assert.validURL('http://test.com', 'http URL')).not.toThrow(); + + expect(() => Assert.validURL('', 'empty URL')).toThrow(BaseError); + expect(() => Assert.validURL('invalid-url', 'malformed URL')).toThrow(BaseError); + expect(() => Assert.validURL('https://user:pass@example.com', 'URL with credentials')).toThrow(BaseError); + expect(() => Assert.validURL('https://example.com/path', 'URL with path')).toThrow(BaseError); + expect(() => Assert.validURL('https://example.com?query=1', 'URL with query')).toThrow(BaseError); + expect(() => Assert.validURL('https://example.com#fragment', 'URL with fragment')).toThrow(BaseError); + }); +}); diff --git a/tests/unit/config-edge-cases.test.ts b/tests/unit/config-edge-cases.test.ts new file mode 100644 index 0000000..7a5a369 --- /dev/null +++ b/tests/unit/config-edge-cases.test.ts @@ -0,0 +1,37 @@ +import { Config } from '../../src/index.js'; +import { BaseError } from '../../src/errors/index.js'; + +describe('Config Edge Cases', () => { + it('should handle empty string parameters', () => { + expect(() => new Config('', '', '', '')).toThrow(BaseError); + }); + + it('should handle null parameters', () => { + expect( + () => + new Config( + null as unknown as string, + null as unknown as string, + null as unknown as string, + null as unknown as string, + ), + ).toThrow(BaseError); + }); + + it('should handle undefined parameters', () => { + expect( + () => + new Config( + undefined as unknown as string, + undefined as unknown as string, + undefined as unknown as string, + undefined as unknown as string, + ), + ).toThrow(BaseError); + }); + + it('should validate URL formats', () => { + expect(() => new Config('valid', 'secret', 'invalid-url', 'backend')).toThrow(BaseError); + expect(() => new Config('valid', 'secret', 'frontend', 'invalid-url')).toThrow(BaseError); + }); +}); diff --git a/tests/unit/errors.test.ts b/tests/unit/errors.test.ts new file mode 100644 index 0000000..86f8fed --- /dev/null +++ b/tests/unit/errors.test.ts @@ -0,0 +1,119 @@ +import { BaseError, ServerError, ValidationError } from '../../src/errors/index.js'; +import { ValidationErrorNames } from '../../src/errors/validationError.js'; + +describe('Error Classes', () => { + describe('BaseError', () => { + it('should create BaseError with all properties', () => { + const error = new BaseError('Test Error', 1001, 'Test message', true); + + expect(error.name).toBe('Test Error'); + expect(error.errorCode).toBe(1001); + expect(error.message).toBe('Test message'); + expect(error.isRetryable).toBe(true); + }); + + it('should create BaseError with default isRetryable false', () => { + const error = new BaseError('Default Error', 2001, 'Default message'); + + expect(error.name).toBe('Default Error'); + expect(error.errorCode).toBe(2001); + expect(error.message).toBe('Default message'); + expect(error.isRetryable).toBe(false); + }); + + it('should be instance of Error', () => { + const error = new BaseError('Instance Test', 3001, 'Instance message'); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(BaseError); + }); + }); + + describe('ValidationError', () => { + it('should create ValidationError with enum name', () => { + const error = new ValidationError(ValidationErrorNames.JWTExpired, true, 'Custom description'); + + expect(error.name).toBe(ValidationErrorNames.JWTExpired); + expect(error.isRetryable).toBe(true); + expect(error.message).toBe('Custom description'); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(ValidationError); + expect(error).toBeInstanceOf(BaseError); + }); + + it('should create ValidationError with default values', () => { + const error = new ValidationError(ValidationErrorNames.JWTInvalid); + + expect(error.name).toBe(ValidationErrorNames.JWTInvalid); + expect(error.isRetryable).toBe(false); + expect(error).toBeInstanceOf(ValidationError); + }); + }); + + describe('ServerError', () => { + it('should create ServerError with response data', () => { + const requestData = { requestID: 'req-123', link: 'https://example.com' }; + const errorDetails = { validation: [{ field: 'test', message: 'Test error' }] }; + const error = new ServerError(5001, 'Server message', requestData, 1.5, errorDetails); + + expect(error.httpStatusCode).toBe(5001); + expect(error.requestData).toEqual(requestData); + expect(error.runtime).toBe(1.5); + expect(error.error).toEqual(errorDetails); + }); + + it('should use getter methods correctly', () => { + const requestData = { requestID: 'req-456', link: 'https://test.com' }; + const errorDetails = { validation: [{ field: 'email', message: 'Invalid email' }] }; + const error = new ServerError(400, 'Bad Request', requestData, 2.3, errorDetails); + + expect(error.getHttpStatusCode()).toBe(400); + expect(error.getRequestData()).toEqual(requestData); + expect(error.getRequestId()).toBe('req-456'); + expect(error.getRuntime()).toBe(2.3); + expect(error.getError()).toEqual(errorDetails); + }); + + it('should handle validation messages correctly', () => { + const requestData = { requestID: 'req-789', link: 'https://validation.com' }; + const errorDetails = { + validation: [ + { field: 'email', message: 'Invalid email format' }, + { field: 'password', message: 'Password too short' }, + ], + }; + const error = new ServerError(422, 'Validation Error', requestData, 1.0, errorDetails); + + const validationMessages = error.getValidationMessages(); + expect(validationMessages).toEqual(['email: Invalid email format', 'password: Password too short']); + + expect(error.message).toContain('email: Invalid email format, password: Password too short'); + }); + + it('should handle empty validation messages', () => { + const requestData = { requestID: 'req-empty', link: 'https://empty.com' }; + const errorDetails = {}; + const error = new ServerError(500, 'Internal Error', requestData, 0.5, errorDetails); + + expect(error.getValidationMessages()).toEqual([]); + expect(error.message).toContain('validation messages: '); + }); + + it('should handle missing requestData gracefully', () => { + const requestData = { requestID: '', link: '' }; + const errorDetails = {}; + const error = new ServerError(404, 'Not Found', requestData, 0.1, errorDetails); + + expect(error.getRequestId()).toBe(''); + }); + + it('should handle missing requestID gracefully', () => { + const requestData = { requestID: '', link: 'https://test.com' }; + const errorDetails = {}; + const error = new ServerError(503, 'Service Unavailable', requestData, 3.0, errorDetails); + + expect(error.getRequestId()).toBe(''); + expect(error.getRequestData()).toEqual(requestData); + }); + }); +}); diff --git a/tests/unit/helpers.test.ts b/tests/unit/helpers.test.ts new file mode 100644 index 0000000..86aa14a --- /dev/null +++ b/tests/unit/helpers.test.ts @@ -0,0 +1,234 @@ +import { AxiosError } from 'axios'; +import Helper from '../../src/helpers/helpers.js'; +import { BaseError, ServerError } from '../../src/errors/index.js'; +import { ServerErrorType } from '../../src/errors/serverError.js'; + +describe('Helper Class', () => { + describe('jsonEncode', () => { + it('should encode valid objects to JSON', () => { + const data = { name: 'test', value: 123 }; + const result = Helper.jsonEncode(data); + + expect(result).toBe('{"name":"test","value":123}'); + }); + + it('should encode arrays to JSON', () => { + const data = [1, 2, 3]; + const result = Helper.jsonEncode(data); + + expect(result).toBe('[1,2,3]'); + }); + + it('should encode primitives to JSON', () => { + expect(Helper.jsonEncode('string')).toBe('"string"'); + expect(Helper.jsonEncode(123)).toBe('123'); + expect(Helper.jsonEncode(true)).toBe('true'); + expect(Helper.jsonEncode(null)).toBe('null'); + }); + }); + + describe('jsonDecode', () => { + it('should decode valid JSON strings', () => { + const jsonString = '{"name":"test","value":123}'; + const result = Helper.jsonDecode(jsonString); + + expect(result).toEqual({ name: 'test', value: 123 }); + }); + + it('should decode array JSON strings', () => { + const jsonString = '[1,2,3]'; + const result = Helper.jsonDecode(jsonString); + + expect(result).toEqual([1, 2, 3]); + }); + + it('should throw error for empty string', () => { + expect(() => Helper.jsonDecode('')).toThrow(BaseError); + }); + + it('should throw error for invalid JSON', () => { + expect(() => Helper.jsonDecode('invalid json')).toThrow(BaseError); + expect(() => Helper.jsonDecode('{"incomplete":')).toThrow(BaseError); + }); + }); + + describe('isErrorHttpStatusCode', () => { + it('should return false for success status codes', () => { + expect(Helper.isErrorHttpStatusCode(200)).toBe(false); + expect(Helper.isErrorHttpStatusCode(201)).toBe(false); + expect(Helper.isErrorHttpStatusCode(204)).toBe(false); + expect(Helper.isErrorHttpStatusCode(299)).toBe(false); + }); + + it('should return true for error status codes', () => { + expect(Helper.isErrorHttpStatusCode(300)).toBe(true); + expect(Helper.isErrorHttpStatusCode(400)).toBe(true); + expect(Helper.isErrorHttpStatusCode(404)).toBe(true); + expect(Helper.isErrorHttpStatusCode(500)).toBe(true); + }); + }); + + describe('throwServerExceptionOld', () => { + it('should throw ServerError with complete data', () => { + const serverErrorData: ServerErrorType = { + httpStatusCode: 400, + message: 'Bad Request', + requestData: { requestID: 'req-123', link: 'https://test.com' }, + runtime: 1.5, + error: { validation: [{ field: 'email', message: 'Invalid email' }] }, + }; + + expect(() => Helper.throwServerExceptionOld(serverErrorData)).toThrow(ServerError); + }); + + it('should throw ServerError with missing error field', () => { + const serverErrorData = { + httpStatusCode: 500, + message: 'Internal Error', + requestData: { requestID: 'req-456', link: 'https://error.com' }, + runtime: 2.0, + } as ServerErrorType; + + expect(() => Helper.throwServerExceptionOld(serverErrorData)).toThrow(ServerError); + }); + + it('should throw error for missing required keys', () => { + const incompleteData = { + httpStatusCode: 400, + message: 'Bad Request', + } as ServerErrorType; + + expect(() => Helper.throwServerExceptionOld(incompleteData)).toThrow(BaseError); + }); + }); + + describe('convertToServerError', () => { + it('should convert AxiosError with server error response', () => { + const serverErrorData = { + httpStatusCode: 422, + message: 'Validation Error', + requestData: { requestID: 'req-789', link: 'https://validation.com' }, + runtime: 0.8, + error: { validation: [{ field: 'name', message: 'Required' }] }, + }; + + // Create a proper AxiosError instance + const axiosError = new AxiosError('Server Error'); + axiosError.response = { + data: serverErrorData, + status: 422, + statusText: 'Unprocessable Entity', + headers: {}, + config: {} as any, + }; + + const result = Helper.convertToServerError(axiosError, 'test origin'); + + expect(result).toBeInstanceOf(ServerError); + expect(result.httpStatusCode).toBe(422); + expect(result.message).toContain('test origin'); + expect(result.requestData).toEqual(serverErrorData.requestData); + }); + + it('should convert AxiosError with generic response', () => { + // Create a proper AxiosError instance + const axiosError = new AxiosError('Not Found'); + axiosError.response = { + data: { message: 'Not found' }, + status: 404, + statusText: 'Not Found', + headers: {}, + config: {} as any, + }; + + const result = Helper.convertToServerError(axiosError, 'test origin'); + + expect(result).toBeInstanceOf(ServerError); + expect(result.httpStatusCode).toBe(404); + expect(result.requestData.requestID).toBe('test origin'); + expect(result.message).toContain('Not found'); + }); + + it('should convert AxiosError without response', () => { + // Create a proper AxiosError instance without response + const axiosError = new AxiosError('Network connection failed'); + + const result = Helper.convertToServerError(axiosError, 'network test'); + + expect(result).toBeInstanceOf(ServerError); + expect(result.httpStatusCode).toBe(500); + expect(result.message).toContain('network test'); + }); + + it('should convert generic error to ServerError', () => { + const genericError = new Error('Something went wrong'); + + const result = Helper.convertToServerError(genericError, 'generic test'); + + expect(result).toBeInstanceOf(ServerError); + expect(result.httpStatusCode).toBe(500); + expect(result.message).toContain('generic test'); + expect(result.error.validation?.[0]?.field).toBe('Error'); + expect(result.error.validation?.[0]?.message).toBe('Something went wrong'); + }); + + it('should convert unknown error to ServerError', () => { + const unknownError = { someProperty: 'value' }; + + const result = Helper.convertToServerError(unknownError, 'unknown test'); + + expect(result).toBeInstanceOf(ServerError); + expect(result.httpStatusCode).toBe(500); + expect(result.message).toContain('unknown test'); + }); + }); + + describe('hydrateRequestData', () => { + it('should create RequestData from valid input', () => { + const input = { requestID: 'req-123', link: 'https://test.com' }; + const result = Helper.hydrateRequestData(input); + + expect(result).toEqual({ + requestID: 'req-123', + link: 'https://test.com', + }); + }); + + it('should throw error for missing keys', () => { + const incompleteInput = { requestID: 'req-123' }; + + expect(() => Helper.hydrateRequestData(incompleteInput)).toThrow(BaseError); + }); + }); + + describe('hydrateResponse', () => { + it('should create GenericRsp from ServerErrorType', () => { + const serverErrorData: ServerErrorType = { + httpStatusCode: 200, + message: 'Success', + requestData: { requestID: 'req-success', link: 'https://success.com' }, + runtime: 0.5, + error: {}, + }; + + const result = Helper.hydrateResponse(serverErrorData); + + expect(result.httpStatusCode).toBe(200); + expect(result.message).toBe('Success'); + expect(result.requestData).toEqual({ + requestID: 'req-success', + link: 'https://success.com', + }); + expect(result.runtime).toBe(0.5); + }); + + it('should throw error for missing required keys', () => { + const incompleteData = { + httpStatusCode: 200, + message: 'Success', + } as ServerErrorType; + + expect(() => Helper.hydrateResponse(incompleteData)).toThrow(BaseError); + }); + }); +}); diff --git a/tests/unit/identifier-service.test.ts b/tests/unit/identifier-service.test.ts new file mode 100644 index 0000000..9f9fd24 --- /dev/null +++ b/tests/unit/identifier-service.test.ts @@ -0,0 +1,271 @@ +import IdentifierService from '../../src/services/identifierService.js'; +import { BaseError } from '../../src/errors/index.js'; +import { + IdentifierCreateReq, + IdentifierType, + IdentifierStatus, + IdentifierUpdateReq, + Identifier as IdentifierRsp, + GenericRsp, + IdentifierList, +} from '../../src/generated/index.js'; +import Utils from '../utils.js'; + +describe('IdentifierService Unit Tests', () => { + let identifierService: IdentifierService; + let mockAxiosInstance: any; + + beforeEach(() => { + const { axiosInstance, mock } = Utils.MockAxiosInstance(); + mockAxiosInstance = mock; + identifierService = new IdentifierService(axiosInstance); + }); + + afterEach(() => { + mockAxiosInstance.reset(); + }); + + describe('constructor', () => { + it('should throw error when axios instance is null', () => { + expect(() => new IdentifierService(null as any)).toThrow(BaseError); + }); + + it('should create IdentifierService instance with valid axios', () => { + const { axiosInstance } = Utils.MockAxiosInstance(); + const service = new IdentifierService(axiosInstance); + expect(service).toBeInstanceOf(IdentifierService); + }); + }); + + describe('create', () => { + const validCreateRequest: IdentifierCreateReq = { + identifierType: IdentifierType.Email, + identifierValue: 'test@example.com', + status: IdentifierStatus.Primary, + }; + + const mockIdentifierResponse: IdentifierRsp = { + identifierID: 'idf-123', + userID: 'usr-456', + type: IdentifierType.Email, + value: 'test@example.com', + status: IdentifierStatus.Primary, + }; + + it('should create identifier successfully', async () => { + mockAxiosInstance.onPost().reply(200, mockIdentifierResponse); + + const result = await identifierService.create('usr-456', validCreateRequest); + + expect(result).toEqual(mockIdentifierResponse); + }); + + it('should throw error for null request', async () => { + await expect(identifierService.create('usr-456', null as any)).rejects.toThrow(); + }); + + it('should handle error response from API', async () => { + const errorResponse = { error: 'Identifier creation failed' }; + mockAxiosInstance.onPost().reply(200, errorResponse); + + await expect(identifierService.create('usr-456', validCreateRequest)).rejects.toThrow(); + }); + + it('should handle network errors during creation', async () => { + mockAxiosInstance.onPost().networkError(); + + await expect(identifierService.create('usr-456', validCreateRequest)).rejects.toThrow(); + }); + }); + + describe('delete', () => { + const mockDeleteResponse: GenericRsp = { + httpStatusCode: 200, + message: 'Identifier deleted successfully', + requestData: { requestID: 'req-123', link: '' }, + runtime: 0.5, + }; + + it('should delete identifier successfully', async () => { + mockAxiosInstance.onDelete().reply(200, mockDeleteResponse); + + const result = await identifierService.delete('usr-123', 'idf-456'); + + expect(result).toEqual(mockDeleteResponse); + }); + + it('should throw error for empty userId', async () => { + await expect(identifierService.delete('', 'idf-456')).rejects.toThrow(); + }); + + it('should throw error for empty identifierId', async () => { + await expect(identifierService.delete('usr-123', '')).rejects.toThrow(); + }); + + it('should handle error response from delete API', async () => { + const errorResponse = { error: 'Delete failed' }; + mockAxiosInstance.onDelete().reply(200, errorResponse); + + await expect(identifierService.delete('usr-123', 'idf-456')).rejects.toThrow(); + }); + + it('should handle network errors during deletion', async () => { + mockAxiosInstance.onDelete().networkError(); + + await expect(identifierService.delete('usr-123', 'idf-456')).rejects.toThrow(); + }); + }); + + describe('list', () => { + const mockListResponse: IdentifierList = { + identifiers: [ + { + identifierID: 'idf-123', + userID: 'usr-456', + type: IdentifierType.Email, + value: 'test@example.com', + status: IdentifierStatus.Primary, + }, + ], + paging: { + page: 1, + totalPages: 1, + totalItems: 1, + }, + }; + + it('should list identifiers with default parameters', async () => { + mockAxiosInstance.onGet().reply(200, mockListResponse); + + const result = await identifierService.list(); + + expect(result).toEqual(mockListResponse); + }); + + it('should list identifiers with custom parameters', async () => { + mockAxiosInstance.onGet().reply(200, mockListResponse); + + const result = await identifierService.list(['filter1'], 'created:asc', 2, 20); + + expect(result).toEqual(mockListResponse); + }); + + it('should handle error response from API', async () => { + const errorResponse = { error: 'List failed' }; + mockAxiosInstance.onGet().reply(200, errorResponse); + + await expect(identifierService.list()).rejects.toThrow(); + }); + }); + + describe('listByValueAndType', () => { + const mockListResponse: IdentifierList = { + identifiers: [], + paging: { page: 1, totalPages: 0, totalItems: 0 }, + }; + + it('should list identifiers by value and type', async () => { + mockAxiosInstance.onGet().reply(200, mockListResponse); + + const result = await identifierService.listByValueAndType('test@example.com', IdentifierType.Email); + + expect(result).toEqual(mockListResponse); + }); + }); + + describe('listByUserId', () => { + const mockListResponse: IdentifierList = { + identifiers: [], + paging: { page: 1, totalPages: 0, totalItems: 0 }, + }; + + it('should list identifiers by userId with prefix', async () => { + mockAxiosInstance.onGet().reply(200, mockListResponse); + + const result = await identifierService.listByUserId('usr-123'); + + expect(result).toEqual(mockListResponse); + }); + + it('should list identifiers by userId without prefix', async () => { + mockAxiosInstance.onGet().reply(200, mockListResponse); + + const result = await identifierService.listByUserId('123'); + + expect(result).toEqual(mockListResponse); + }); + }); + + describe('listByUserIdAndType', () => { + const mockListResponse: IdentifierList = { + identifiers: [], + paging: { page: 1, totalPages: 0, totalItems: 0 }, + }; + + it('should list identifiers by userId and type with prefix', async () => { + mockAxiosInstance.onGet().reply(200, mockListResponse); + + const result = await identifierService.listByUserIdAndType('usr-123', IdentifierType.Email); + + expect(result).toEqual(mockListResponse); + }); + + it('should list identifiers by userId and type without prefix', async () => { + mockAxiosInstance.onGet().reply(200, mockListResponse); + + const result = await identifierService.listByUserIdAndType('123', IdentifierType.Email); + + expect(result).toEqual(mockListResponse); + }); + }); + + describe('updateIdentifier', () => { + const validUpdateRequest: IdentifierUpdateReq = { status: IdentifierStatus.Verified }; + + const mockIdentifierResponse: IdentifierRsp = { + identifierID: 'idf-123', + userID: 'usr-456', + type: IdentifierType.Email, + value: 'test@example.com', + status: IdentifierStatus.Verified, + }; + + it('should update identifier successfully', async () => { + mockAxiosInstance.onAny().reply(200, mockIdentifierResponse); + + const result = await identifierService.updateIdentifier('usr-456', 'idf-123', validUpdateRequest); + + expect(result).toEqual(mockIdentifierResponse); + }); + + it('should throw error for empty userId', async () => { + await expect(identifierService.updateIdentifier('', 'idf-123', validUpdateRequest)).rejects.toThrow(); + }); + + it('should throw error for empty identifierId', async () => { + await expect(identifierService.updateIdentifier('usr-456', '', validUpdateRequest)).rejects.toThrow(); + }); + + it('should throw error for null update request', async () => { + await expect(identifierService.updateIdentifier('usr-456', 'idf-123', null as any)).rejects.toThrow(); + }); + }); + + describe('updateStatus', () => { + const mockIdentifierResponse: IdentifierRsp = { + identifierID: 'idf-123', + userID: 'usr-456', + type: IdentifierType.Email, + value: 'test@example.com', + status: IdentifierStatus.Verified, + }; + + it('should update identifier status successfully', async () => { + mockAxiosInstance.onAny().reply(200, mockIdentifierResponse); + + const result = await identifierService.updateStatus('usr-456', 'idf-123', IdentifierStatus.Verified); + + expect(result).toEqual(mockIdentifierResponse); + }); + }); +}); diff --git a/tests/unit/sdk-core.test.ts b/tests/unit/sdk-core.test.ts new file mode 100644 index 0000000..13a4bb5 --- /dev/null +++ b/tests/unit/sdk-core.test.ts @@ -0,0 +1,89 @@ +import { Config, SDK } from '../../src/index.js'; +import { BaseError } from '../../src/errors/index.js'; + +describe('SDK Core Functionality', () => { + let projectID: string; + let apiSecret: string; + let frontendAPI: string; + let backendAPI: string; + let config: Config; + + beforeEach(() => { + projectID = process.env.CORBADO_PROJECT_ID as string; + apiSecret = process.env.CORBADO_API_SECRET as string; + frontendAPI = process.env.CORBADO_FRONTEND_API as string; + backendAPI = process.env.CORBADO_BACKEND_API as string; + + if (!projectID || !apiSecret) { + throw new BaseError('Env Error', 5001, 'Both projectID and apiSecret must be defined', true); + } + + config = new Config(projectID, apiSecret, frontendAPI, backendAPI); + }); + + it('should create SDK instance successfully', () => { + const sdk = new SDK(config); + + expect(sdk).toBeDefined(); + expect(sdk.sessions()).toBeDefined(); + expect(sdk.users()).toBeDefined(); + expect(sdk.identifiers()).toBeDefined(); + }); + + it('should create axios client with correct configuration', () => { + const sdk = new SDK(config); + const axiosClient = sdk.createClient(config); + + expect(axiosClient).toBeDefined(); + expect(axiosClient.defaults.baseURL).toBe(`${backendAPI}/v2`); + expect(axiosClient.defaults.auth).toEqual({ + username: projectID, + password: apiSecret, + }); + expect(axiosClient.defaults.headers['X-Corbado-ProjectID']).toBe(projectID); + expect(axiosClient.defaults.headers['X-Corbado-SDK']).toBeDefined(); + }); + + it('should set correct SDK headers', () => { + const sdk = new SDK(config); + const axiosClient = sdk.createClient(config); + + const sdkHeader = JSON.parse(axiosClient.defaults.headers['X-Corbado-SDK'] as string); + expect(sdkHeader.name).toBe('Node.js SDK'); + expect(sdkHeader.languageVersion).toBe(process.version); + }); + + it('should throw error in browser environment', () => { + // Mock browser environment + const originalWindow = global.window; + const originalDocument = global.document; + + // @ts-ignore + global.window = { document: {} }; + // @ts-ignore + global.document = {}; + + expect(() => new SDK(config)).toThrow('This SDK is not supported in browser environment'); + + // Restore original environment + global.window = originalWindow; + global.document = originalDocument; + }); + + it('should return correct service instances', () => { + const sdk = new SDK(config); + + const sessionService = sdk.sessions(); + const userService = sdk.users(); + const identifierService = sdk.identifiers(); + + expect(sessionService).toBeDefined(); + expect(userService).toBeDefined(); + expect(identifierService).toBeDefined(); + + // Should return the same instances on subsequent calls + expect(sdk.sessions()).toBe(sessionService); + expect(sdk.users()).toBe(userService); + expect(sdk.identifiers()).toBe(identifierService); + }); +}); diff --git a/tests/unit/sdk-error-handling.test.ts b/tests/unit/sdk-error-handling.test.ts new file mode 100644 index 0000000..fd2c588 --- /dev/null +++ b/tests/unit/sdk-error-handling.test.ts @@ -0,0 +1,13 @@ +import { Config } from '../../src/index.js'; + +describe('SDK Error Handling', () => { + it('should create config successfully with valid parameters', () => { + expect( + () => new Config('pro-test', 'corbado1_secret', 'https://frontend.com', 'https://backend.com'), + ).not.toThrow(); + }); + + it('should handle invalid config in createClient', () => { + expect(() => new Config('', '', '', '')).toThrow(); + }); +}); diff --git a/tests/unit/services-coverage.test.ts b/tests/unit/services-coverage.test.ts new file mode 100644 index 0000000..3f076af --- /dev/null +++ b/tests/unit/services-coverage.test.ts @@ -0,0 +1,26 @@ +import Utils from '../utils.js'; +import { SDK } from '../../src/index.js'; + +describe('Services Coverage', () => { + let sdk: SDK; + + beforeEach(() => { + sdk = Utils.SDK(); + }); + + it('should return session service instance', () => { + const sessions = sdk.sessions(); + expect(sessions).toBeDefined(); + expect(typeof sessions.validateToken).toBe('function'); + }); + + it('should return user service instance', () => { + const users = sdk.users(); + expect(users).toBeDefined(); + }); + + it('should return identifier service instance', () => { + const identifiers = sdk.identifiers(); + expect(identifiers).toBeDefined(); + }); +}); diff --git a/tests/unit/session.test.ts b/tests/unit/session.test.ts index 36ae5ef..0cd16fc 100644 --- a/tests/unit/session.test.ts +++ b/tests/unit/session.test.ts @@ -69,22 +69,16 @@ async function generateJWT( return `${encodedHeader}.${encodedPayload}.`; } - return await new SignJWT(payload) + return new SignJWT(payload) .setProtectedHeader({ - alg: alg, + alg, kid: 'kid123', }) .sign(privateKey); } function createSessionService(issuer: string): SessionService { - return new SessionService( - 'cbo_session_token', - issuer, - `http://localhost:${PORT}/jwks`, - 10, - 'pro-1', - ); + return new SessionService('cbo_session_token', issuer, `http://localhost:${PORT}/jwks`, 10, 'pro-1'); } describe('Session Service Unit Tests', () => { @@ -99,19 +93,26 @@ describe('Session Service Unit Tests', () => { }); test('should throw error if required parameters are missing in constructor', () => { - expect(() => new SessionService('', 'https://pro-1.frontendapi.cloud.corbado.io', `http://localhost:${PORT}/jwks`, 10, 'pro-1')).toThrow( - 'Required parameter is empty', - ); + expect( + () => + new SessionService( + '', + 'https://pro-1.frontendapi.cloud.corbado.io', + `http://localhost:${PORT}/jwks`, + 10, + 'pro-1', + ), + ).toThrow('Required parameter is empty'); expect(() => new SessionService('cbo_session_token', '', `http://localhost:${PORT}/jwks`, 10, 'pro-1')).toThrow( 'Required parameter is empty', ); - expect(() => new SessionService('cbo_session_token', 'https://pro-1.frontendapi.cloud.corbado.io', '', 10, 'pro-1')).toThrow( - 'Required parameter is empty', - ); + expect( + () => new SessionService('cbo_session_token', 'https://pro-1.frontendapi.cloud.corbado.io', '', 10, 'pro-1'), + ).toThrow('Required parameter is empty'); }); test('should throw ValidationError if JWT is empty', async () => { - const sessionService = createSessionService('https://pro-1.frontendapi.cloud.corbado.io') + const sessionService = createSessionService('https://pro-1.frontendapi.cloud.corbado.io'); await expect(sessionService.validateToken('')).rejects.toThrow(BaseError); await expect(sessionService.validateToken('')).rejects.toHaveProperty( @@ -121,7 +122,7 @@ describe('Session Service Unit Tests', () => { }); test('should throw ValidationError if JWT is too short', async () => { - const sessionService = createSessionService('https://pro-1.frontendapi.cloud.corbado.io') + const sessionService = createSessionService('https://pro-1.frontendapi.cloud.corbado.io'); await expect(sessionService.validateToken('short')).rejects.toThrow(ValidationError); await expect(sessionService.validateToken('short')).rejects.toHaveProperty( @@ -131,18 +132,16 @@ describe('Session Service Unit Tests', () => { }); test('should throw ValidationError if JWT has an invalid signature', async () => { - const sessionService = createSessionService('https://pro-1.frontendapi.cloud.corbado.io') + const sessionService = createSessionService('https://pro-1.frontendapi.cloud.corbado.io'); - const jwt = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImtpZDEyMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguYWNtZS5jb20iLCJpYXQiOjE3MjY0OTE4MDcsImV4cCI6MTcyNjQ5MTkwNywibmJmIjoxNzI2NDkxNzA3LCJzdWIiOiJ1c3ItMTIzNDU2Nzg5MCIsIm5hbWUiOiJuYW1lIiwiZW1haWwiOiJlbWFpbCIsInBob25lX251bWJlciI6InBob25lTnVtYmVyIiwib3JpZyI6Im9yaWcifQ.invalid'; + const jwt = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImtpZDEyMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguYWNtZS5jb20iLCJpYXQiOjE3MjY0OTE4MDcsImV4cCI6MTcyNjQ5MTkwNywibmJmIjoxNzI2NDkxNzA3LCJzdWIiOiJ1c3ItMTIzNDU2Nzg5MCIsIm5hbWUiOiJuYW1lIiwiZW1haWwiOiJlbWFpbCIsInBob25lX251bWJlciI6InBob25lTnVtYmVyIiwib3JpZyI6Im9yaWcifQ.invalid'; await expect(sessionService.validateToken(jwt)).rejects.toThrow(ValidationError); - await expect(sessionService.validateToken(jwt)).rejects.toHaveProperty( - 'name', - ValidationErrorNames.JWTInvalid, - ); + await expect(sessionService.validateToken(jwt)).rejects.toHaveProperty('name', ValidationErrorNames.JWTInvalid); }); test('should throw ValidationError using invalid private key', async () => { - const sessionService = createSessionService('https://pro-1.frontendapi.cloud.corbado.io') + const sessionService = createSessionService('https://pro-1.frontendapi.cloud.corbado.io'); const jwt = await generateJWT('https://pro-1.frontendapi.cloud.corbado.io', 600, 0, invalidPrivateKey, 'RS256'); await expect(sessionService.validateToken(jwt)).rejects.toThrow(ValidationError); @@ -150,7 +149,7 @@ describe('Session Service Unit Tests', () => { }); test('should throw ValidationError using alg "none"', async () => { - const sessionService = createSessionService('https://pro-1.frontendapi.cloud.corbado.io') + const sessionService = createSessionService('https://pro-1.frontendapi.cloud.corbado.io'); const jwt = await generateJWT('https://pro-1.frontendapi.cloud.corbado.io', 600, 0, invalidPrivateKey, 'none'); await expect(sessionService.validateToken(jwt)).rejects.toThrow(ValidationError); @@ -158,7 +157,7 @@ describe('Session Service Unit Tests', () => { }); test('should throw ValidationError if JWT is not yet valid (nbf claim in the future)', async () => { - const sessionService = createSessionService('https://pro-1.frontendapi.cloud.corbado.io') + const sessionService = createSessionService('https://pro-1.frontendapi.cloud.corbado.io'); const jwt = await generateJWT('https://pro-1.frontendapi.cloud.corbado.io', 600, 600, validPrivateKey, 'RS256'); await expect(sessionService.validateToken(jwt)).rejects.toThrow(ValidationError); @@ -169,7 +168,7 @@ describe('Session Service Unit Tests', () => { }); test('should throw ValidationError using an expired JWT', async () => { - const sessionService = createSessionService('https://pro-1.frontendapi.cloud.corbado.io') + const sessionService = createSessionService('https://pro-1.frontendapi.cloud.corbado.io'); const jwt = await generateJWT('https://pro-1.frontendapi.cloud.corbado.io', -600, 0, validPrivateKey, 'RS256'); await expect(sessionService.validateToken(jwt)).rejects.toThrow(ValidationError); @@ -177,7 +176,7 @@ describe('Session Service Unit Tests', () => { }); test('should throw ValidationError if issuer is empty', async () => { - const sessionService = createSessionService('https://pro-1.frontendapi.cloud.corbado.io') + const sessionService = createSessionService('https://pro-1.frontendapi.cloud.corbado.io'); const jwt = await generateJWT('', 600, 0, validPrivateKey, 'RS256'); await expect(sessionService.validateToken(jwt)).rejects.toThrow(ValidationError); @@ -185,7 +184,7 @@ describe('Session Service Unit Tests', () => { }); test('should throw ValidationError if issuer is mismatch 1', async () => { - const sessionService = createSessionService('https://pro-1.frontendapi.corbado.io') + const sessionService = createSessionService('https://pro-1.frontendapi.corbado.io'); const jwt = await generateJWT('https://pro-2.frontendapi.cloud.corbado.io', 600, 0, validPrivateKey, 'RS256'); await expect(sessionService.validateToken(jwt)).rejects.toThrow(ValidationError); @@ -193,7 +192,7 @@ describe('Session Service Unit Tests', () => { }); test('should throw ValidationError if issuer is mismatch 2', async () => { - const sessionService = createSessionService('https://pro-1.frontendapi.cloud.corbado.io') + const sessionService = createSessionService('https://pro-1.frontendapi.cloud.corbado.io'); const jwt = await generateJWT('https://pro-2.frontendapi.corbado.io', 600, 0, validPrivateKey, 'RS256'); await expect(sessionService.validateToken(jwt)).rejects.toThrow(ValidationError); @@ -201,7 +200,7 @@ describe('Session Service Unit Tests', () => { }); test('should return user using old Frontend API URL as issuer in JWT', async () => { - const sessionService = createSessionService('https://pro-1.frontendapi.cloud.corbado.io') + const sessionService = createSessionService('https://pro-1.frontendapi.cloud.corbado.io'); const jwt = await generateJWT('https://pro-1.frontendapi.corbado.io', 600, 0, validPrivateKey, 'RS256'); const user = await sessionService.validateToken(jwt); @@ -210,7 +209,7 @@ describe('Session Service Unit Tests', () => { }); test('should return user using old Frontend API URL as issuer in config', async () => { - const sessionService = createSessionService('https://pro-1.frontendapi.corbado.io') + const sessionService = createSessionService('https://pro-1.frontendapi.corbado.io'); const jwt = await generateJWT('https://pro-1.frontendapi.cloud.corbado.io', 600, 0, validPrivateKey, 'RS256'); const user = await sessionService.validateToken(jwt); @@ -219,7 +218,7 @@ describe('Session Service Unit Tests', () => { }); test('should return user data using CNAME', async () => { - const sessionService = createSessionService('https://auth.acme.com') + const sessionService = createSessionService('https://auth.acme.com'); const jwt = await generateJWT('https://auth.acme.com', 600, 0, validPrivateKey, 'RS256'); const user = await sessionService.validateToken(jwt); diff --git a/tests/unit/user-service.test.ts b/tests/unit/user-service.test.ts new file mode 100644 index 0000000..9000ace --- /dev/null +++ b/tests/unit/user-service.test.ts @@ -0,0 +1,169 @@ +import { AxiosInstance } from 'axios'; +import UserService from '../../src/services/userService.js'; +import { BaseError } from '../../src/errors/index.js'; +import { UserCreateReq, UserStatus, User, GenericRsp } from '../../src/generated/index.js'; +import Utils from '../utils.js'; + +describe('UserService Unit Tests', () => { + let userService: UserService; + let mockAxiosInstance: any; + + beforeEach(() => { + const { axiosInstance, mock } = Utils.MockAxiosInstance(); + mockAxiosInstance = mock; + userService = new UserService(axiosInstance); + }); + + afterEach(() => { + mockAxiosInstance.reset(); + }); + + describe('constructor', () => { + it('should throw error when axios instance is null', () => { + expect(() => new UserService(null as unknown as AxiosInstance)).toThrow(BaseError); + }); + + it('should create UserService instance with valid axios', () => { + const { axiosInstance } = Utils.MockAxiosInstance(); + const service = new UserService(axiosInstance); + expect(service).toBeInstanceOf(UserService); + }); + }); + + describe('create', () => { + const validUserRequest: UserCreateReq = { + fullName: 'John Doe', + status: UserStatus.Active, + }; + + const mockUserResponse: User = { + userID: 'usr-123', + fullName: 'John Doe', + status: UserStatus.Active, + }; + + it('should create user successfully', async () => { + mockAxiosInstance.onPost().reply(200, mockUserResponse); + + const result = await userService.create(validUserRequest); + + expect(result).toEqual(mockUserResponse); + }); + + it('should throw error for null request', async () => { + await expect(userService.create(null as unknown as UserCreateReq)).rejects.toThrow(); + }); + + it('should throw error for empty fullName', async () => { + const invalidRequest = { ...validUserRequest, fullName: '' }; + + await expect(userService.create(invalidRequest)).rejects.toThrow(); + }); + + it('should throw error for null status', async () => { + const invalidRequest = { ...validUserRequest, status: null as any }; + + await expect(userService.create(invalidRequest)).rejects.toThrow(); + }); + + it('should handle error response from API', async () => { + const errorResponse = { error: 'User creation failed' }; + mockAxiosInstance.onPost().reply(200, errorResponse); + + await expect(userService.create(validUserRequest)).rejects.toThrow(); + }); + + it('should handle network errors', async () => { + mockAxiosInstance.onPost().networkError(); + + await expect(userService.create(validUserRequest)).rejects.toThrow(); + }); + }); + + describe('createActiveByName', () => { + const mockUserResponse: User = { + userID: 'usr-456', + fullName: 'Jane Smith', + status: UserStatus.Active, + }; + + it('should create active user by name', async () => { + mockAxiosInstance.onPost().reply(200, mockUserResponse); + + const result = await userService.createActiveByName('Jane Smith'); + + expect(result).toEqual(mockUserResponse); + }); + + it('should throw error for empty fullName', async () => { + await expect(userService.createActiveByName('')).rejects.toThrow(); + }); + }); + + describe('delete', () => { + const mockDeleteResponse: GenericRsp = { + httpStatusCode: 200, + message: 'User deleted successfully', + requestData: { requestID: 'req-123', link: '' }, + runtime: 0.5, + }; + + it('should delete user successfully', async () => { + mockAxiosInstance.onDelete().reply(200, mockDeleteResponse); + + const result = await userService.delete('usr-123'); + + expect(result).toEqual(mockDeleteResponse); + }); + + it('should throw error for empty id', async () => { + await expect(userService.delete('')).rejects.toThrow(); + }); + + it('should handle error response from API', async () => { + const errorResponse = { error: 'User not found' }; + mockAxiosInstance.onDelete().reply(200, errorResponse); + + await expect(userService.delete('usr-123')).rejects.toThrow(); + }); + + it('should handle network errors', async () => { + mockAxiosInstance.onDelete().networkError(); + + await expect(userService.delete('usr-123')).rejects.toThrow(); + }); + }); + + describe('get', () => { + const mockUserResponse: User = { + userID: 'usr-789', + fullName: 'Bob Johnson', + status: UserStatus.Active, + }; + + it('should get user successfully', async () => { + mockAxiosInstance.onGet().reply(200, mockUserResponse); + + const result = await userService.get('usr-789'); + + expect(result).toEqual(mockUserResponse); + }); + + it('should throw error for empty id', async () => { + await expect(userService.get('')).rejects.toThrow(); + }); + + it('should handle error response from API', async () => { + const errorResponse = { error: 'User not found' }; + mockAxiosInstance.onGet().reply(200, errorResponse); + + await expect(userService.get('usr-789')).rejects.toThrow(); + }); + + it('should handle network errors', async () => { + mockAxiosInstance.onGet().networkError(); + + await expect(userService.get('usr-789')).rejects.toThrow(); + }); + }); +}); diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts new file mode 100644 index 0000000..2e256d3 --- /dev/null +++ b/tests/unit/utils.test.ts @@ -0,0 +1,29 @@ +import Utils from '../utils.js'; + +describe('Utils Class', () => { + it('should create SDK instance', () => { + const sdk = Utils.SDK(); + expect(sdk).toBeDefined(); + }); + + it('should create Axios instance', () => { + const instance = Utils.AxiosInstance(); + expect(instance).toBeDefined(); + expect(instance.defaults.baseURL).toBe(process.env.CORBADO_BACKEND_API); + }); + + it('should create mock Axios instance', () => { + const { axiosInstance, mock } = Utils.MockAxiosInstance(); + expect(axiosInstance).toBeDefined(); + expect(mock).toBeDefined(); + }); + + it('should generate random test data', () => { + const name = Utils.createRandomTestName(); + const email = Utils.createRandomTestEmail(); + + expect(name).toBeDefined(); + expect(name.length).toBeGreaterThan(0); + expect(email).toMatch(/@corbado\.com$/); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts index e6d9a18..24446d4 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,8 +1,8 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import axios, { AxiosInstance } from 'axios'; -import { SDK, Config } from '../src'; -import { BaseError, httpStatusCodes } from '../src/errors'; -import { User } from '../src/generated'; +import { SDK, Config } from '../src/index.js'; +import { BaseError, httpStatusCodes } from '../src/errors/index.js'; +import { User } from '../src/generated/index.js'; class Utils { public static SDK(): SDK {