Skip to content

Commit

Permalink
tests: update tests and use msw for api mocks
Browse files Browse the repository at this point in the history
  • Loading branch information
alvarosabu committed Oct 16, 2024
1 parent 7449f5f commit 90ccd57
Show file tree
Hide file tree
Showing 11 changed files with 396 additions and 89 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@vitest/coverage-v8": "^2.1.1",
"eslint": "^9.10.0",
"memfs": "^4.11.2",
"msw": "^2.4.11",
"pathe": "^1.1.2",
"typescript": "^5.6.2",
"unbuild": "^2.0.0",
Expand Down
238 changes: 224 additions & 14 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

118 changes: 72 additions & 46 deletions src/commands/login/actions.test.ts
Original file line number Diff line number Diff line change
@@ -1,85 +1,111 @@
import { describe, expect, it, vi } from 'vitest'
import { afterAll, afterEach, beforeAll, expect } from 'vitest'

import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
import { loginWithEmailAndPassword, loginWithOtp, loginWithToken } from './actions'
import { ofetch } from 'ofetch'
import chalk from 'chalk'

vi.mock('ofetch', () => ({
ofetch: vi.fn(),
}))
const emailRegex = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/

describe('login actions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const handlers = [
http.get('https://api.storyblok.com/v1/users/me', async ({ request }) => {
const token = request.headers.get('Authorization')
if (token === 'valid-token') { return HttpResponse.json({ data: 'user data' }) }

Check failure on line 13 in src/commands/login/actions.test.ts

View workflow job for this annotation

GitHub Actions / Lint (20)

This line has 2 statements. Maximum allowed is 1
return new HttpResponse('Unauthorized', { status: 401 })
}),
http.post('https://api.storyblok.com/v1/users/login', async ({ request }) => {
const body = await request.json() as { email: string, password: string }

if (!emailRegex.test(body.email)) {
return new HttpResponse('Unprocessable Entity', { status: 422 })
}

if (body?.email === '[email protected]' && body?.password === 'password') {
return HttpResponse.json({ otp_required: true })
}
else {
return new HttpResponse('Unauthorized', { status: 401 })
}
}),
]

const server = setupServer(...handlers)

// Start server before all tests
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))

// Close server after all tests
afterAll(() => server.close())

// Reset handlers after each test `important for test isolation`
afterEach(() => server.resetHandlers())

Check failure on line 41 in src/commands/login/actions.test.ts

View workflow job for this annotation

GitHub Actions / Lint (20)

`afterEach` hooks should be before any `afterAll` hooks

describe('login actions', () => {
describe('loginWithToken', () => {
it('should login successfully with a valid token', async () => {
const mockResponse = { data: 'user data' }
vi.mocked(ofetch).mockResolvedValue(mockResponse)

const result = await loginWithToken('valid-token', 'eu')
expect(result).toEqual(mockResponse)
})

it('should throw an masked error for invalid token', async () => {
const mockError = {
response: { status: 401 },
data: { error: 'Unauthorized' },
}
vi.mocked(ofetch).mockRejectedValue(mockError)

await expect(loginWithToken('invalid-token', 'eu')).rejects.toThrow(
new Error(`The token provided ${chalk.bold('inva*********')} is invalid: ${chalk.bold('401 Unauthorized')}
Please make sure you are using the correct token and try again.`),
new Error(`The token provided ${chalk.bold('inva*********')} is invalid.
Please make sure you are using the correct token and try again.`),
)
})

it('should throw a network error if response is empty (network)', async () => {
const mockError = new Error('Network error')
vi.mocked(ofetch).mockRejectedValue(mockError)

server.use(
http.get('https://api.storyblok.com/v1/users/me', () => {
return new HttpResponse(null, { status: 500 })
}),
)
await expect(loginWithToken('any-token', 'eu')).rejects.toThrow(
'No response from server, please check if you are correctly connected to internet',
)
})
})

describe('loginWithEmailAndPassword', () => {
it('should login successfully with valid email and password', async () => {
const mockResponse = { data: 'user data' }
vi.mocked(ofetch).mockResolvedValue(mockResponse)

const result = await loginWithEmailAndPassword('[email protected]', 'password', 'eu')
expect(result).toEqual(mockResponse)
it('should get if the user requires otp', async () => {
const expected = { otp_required: true }
const result = await loginWithEmailAndPassword('[email protected]', 'password', 'eu')
expect(result).toEqual(expected)
})

it('should throw a generic error for login failure', async () => {
const mockError = new Error('Network error')
vi.mocked(ofetch).mockRejectedValue(mockError)
it('should throw an error for invalid email', async () => {
await expect(loginWithEmailAndPassword('invalid-email', 'password', 'eu')).rejects.toThrow(
'The provided credentials are invalid',
)
})

await expect(loginWithEmailAndPassword('[email protected]', 'password', 'eu')).rejects.toThrow(
'Error logging in with email and password',
it('should throw an error for invalid credentials', async () => {
await expect(loginWithEmailAndPassword('[email protected]', 'password', 'eu')).rejects.toThrow(
'The user is not authorized to access the API',
)
})
})

describe('loginWithOtp', () => {
it('should login successfully with valid email, password, and otp', async () => {
const mockResponse = { data: 'user data' }
vi.mocked(ofetch).mockResolvedValue(mockResponse)

const result = await loginWithOtp('[email protected]', 'password', '123456', 'eu')
expect(result).toEqual(mockResponse)
})
server.use(
http.post('https://api.storyblok.com/v1/users/login', async ({ request }) => {
const body = await request.json() as { email: string, password: string, otp_attempt: string }
if (body?.email === '[email protected]' && body?.password === 'password' && body?.otp_attempt === '123456') {
return HttpResponse.json({ access_token: 'Awiwi' })
}

else {
return new HttpResponse('Unauthorized', { status: 401 })
}
}),
)
const expected = { access_token: 'Awiwi' }

it('should throw a generic error for login failure', async () => {
const mockError = new Error('Network error')
vi.mocked(ofetch).mockRejectedValue(mockError)
const result = await loginWithOtp('[email protected]', 'password', '123456', 'eu')

await expect(loginWithOtp('[email protected]', 'password', '123456', 'eu')).rejects.toThrow(
'Error logging in with email, password and otp',
)
expect(result).toEqual(expected)
})
})
})
7 changes: 4 additions & 3 deletions src/commands/login/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@ export const loginWithToken = async (token: string, region: RegionCode) => {

switch (status) {
case 401:
throw new APIError('unauthorized', 'login_with_token', error, `The token provided ${chalk.bold(maskToken(token))} is invalid: ${chalk.bold(`401 ${(error as FetchError).data.error}`)}
throw new APIError('unauthorized', 'login_with_token', error, `The token provided ${chalk.bold(maskToken(token))} is invalid.
Please make sure you are using the correct token and try again.`)
case 422:
throw new APIError('invalid_credentials', 'login_with_token', error)
default:
throw new APIError('network_error', 'login_with_token', error)
}
}
else {
throw new APIError('generic', 'login_with_token', error as Error)
}
}
}

Expand Down
23 changes: 14 additions & 9 deletions src/creds.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { access, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { handleError, konsola } from './utils'
import { FileSystemError, handleFileSystemError, konsola } from './utils'
import chalk from 'chalk'
import type { RegionCode } from './constants'

Expand Down Expand Up @@ -55,7 +55,12 @@ const parseNetrcTokens = (tokens: string[]) => {
) {
const key = tokens[i] as keyof NetrcMachine
const value = tokens[++i]
machineData[key] = value
if (key === 'region') {
machineData[key] = value as RegionCode
}
else {
machineData[key] = value
}
i++
}

Expand All @@ -80,7 +85,7 @@ export const getNetrcCredentials = async (filePath: string = getNetrcFilePath())
await access(filePath)
}
catch {
console.warn(`.netrc file not found at path: ${filePath}`)
konsola.warn(`.netrc file not found at path: ${filePath}`)
return {}
}
try {
Expand All @@ -90,7 +95,7 @@ export const getNetrcCredentials = async (filePath: string = getNetrcFilePath())
return machines
}
catch (error) {
console.error('Error reading or parsing .netrc file:', error)
handleFileSystemError('read', error as NodeJS.ErrnoException)
return {}
}
}
Expand Down Expand Up @@ -160,7 +165,7 @@ export const addNetrcEntry = async ({
}
catch {
// File does not exist
console.warn(`.netrc file not found at path: ${filePath}. A new file will be created.`)
konsola.warn(`.netrc file not found at path: ${filePath}. A new file will be created.`)
}

// Add or update the machine entry
Expand All @@ -180,8 +185,8 @@ export const addNetrcEntry = async ({

konsola.ok(`Successfully added/updated entry for machine ${machineName} in ${chalk.hex('#45bfb9')(filePath)}`, true)
}
catch (error: unknown) {
throw new Error(`Error adding/updating entry for machine ${machineName} in .netrc file: ${(error as Error).message}`)
catch (error) {
throw new FileSystemError('invalid_argument', 'write', error as NodeJS.ErrnoException, `Error adding/updating entry for machine ${machineName} in .netrc file`)
}
}

Expand All @@ -202,7 +207,7 @@ export const removeNetrcEntry = async (
}
catch {
// File does not exist
console.warn(`.netrc file not found at path: ${filePath}. No action taken.`)
konsola.warn(`.netrc file not found at path: ${filePath}. No action taken.`)
return
}

Expand Down Expand Up @@ -242,7 +247,7 @@ export async function isAuthorized() {
return false
}
catch (error: unknown) {
handleError(new Error(`Error checking authorization in .netrc file: ${(error as Error).message}`), true)
handleFileSystemError('authorization_check', error as NodeJS.ErrnoException)
return false
}
}
5 changes: 3 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,14 @@ program.on('command:*', () => {

program.command('test').action(async (options) => {

Check failure on line 29 in src/index.ts

View workflow job for this annotation

GitHub Actions / Lint (20)

'options' is defined but never used. Allowed unused args must match /^_/u
konsola.title(`Test`, '#8556D3', 'Attempting a test...')
try {
konsola.error('This is an error message')
/* try {
// await loginWithEmailAndPassword('aw', 'passwrod', 'eu')
await loginWithToken('WYSYDHYASDHSYD', 'eu')
}
catch (error) {
handleError(error as Error)
}
} */
})

/* console.log(`
Expand Down
3 changes: 2 additions & 1 deletion src/utils/error/api-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const API_ERRORS = {
network_error: 'No response from server, please check if you are correctly connected to internet',
invalid_credentials: 'The provided credentials are invalid',
timeout: 'The API request timed out',
generic: 'Error logging in',
} as const

export function handleAPIError(action: keyof typeof API_ACTIONS, error: Error): void {
Expand All @@ -31,7 +32,7 @@ export function handleAPIError(action: keyof typeof API_ACTIONS, error: Error):
throw new APIError('network_error', action, error)
}
else {
throw new APIError('timeout', action, error)
throw new APIError('generic', action, error)
}
}

Expand Down
1 change: 0 additions & 1 deletion src/utils/error/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ export function handleError(error: Error, verbose = false): void {
konsola.error(`Command Error: ${error.getInfo().message}`, errorDetails)
}
else if (error instanceof APIError) {
console.log('error', error)
konsola.error(`API Error: ${error.getInfo().cause}`, errorDetails)
}
else if (error instanceof FileSystemError) {
Expand Down
59 changes: 57 additions & 2 deletions src/utils/error/filesystem-error.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,72 @@
const FS_ERRORS = {
file_not_found: 'The file requested was not found',
permission_denied: 'Permission denied while accessing the file',
operation_on_directory: 'The operation is not allowed on a directory',
not_a_directory: 'The path provided is not a directory',
file_already_exists: 'The file already exists',
directory_not_empty: 'The directory is not empty',
too_many_open_files: 'Too many open files',
no_space_left: 'No space left on the device',
invalid_argument: 'An invalid argument was provided',
unknown_error: 'An unknown error occurred',
}

const FS_ACTIONS = {
read: 'Failed to read/parse the .netrc file:',
write: 'Writing file',
delete: 'Deleting file',
mkdir: 'Creating directory',
rmdir: 'Removing directory',
authorization_check: 'Failed to check authorization in .netrc file:',
}

export function handleFileSystemError(action: keyof typeof FS_ACTIONS, error: NodeJS.ErrnoException): void {
if (error.code) {
switch (error.code) {
case 'ENOENT':
throw new FileSystemError('file_not_found', action, error)
case 'EACCES':
case 'EPERM':
throw new FileSystemError('permission_denied', action, error)
case 'EISDIR':
throw new FileSystemError('operation_on_directory', action, error)
case 'ENOTDIR':
throw new FileSystemError('not_a_directory', action, error)
case 'EEXIST':
throw new FileSystemError('file_already_exists', action, error)
case 'ENOTEMPTY':
throw new FileSystemError('directory_not_empty', action, error)
case 'EMFILE':
throw new FileSystemError('too_many_open_files', action, error)
case 'ENOSPC':
throw new FileSystemError('no_space_left', action, error)
case 'EINVAL':
throw new FileSystemError('invalid_argument', action, error)
default:
throw new FileSystemError('unknown_error', action, error)
}
}
else {
// In case the error does not have a known `fs` error code, throw a general error
throw new FileSystemError('unknown_error', action, error)
}
}

export class FileSystemError extends Error {
errorId: string
cause: string
code: string | undefined
messageStack: string[]
error: NodeJS.ErrnoException | undefined

constructor(message: string, errorId: keyof typeof FS_ERRORS) {
super(message)
constructor(errorId: keyof typeof FS_ERRORS, action: keyof typeof FS_ACTIONS, error: NodeJS.ErrnoException, customMessage?: string) {
super(customMessage || FS_ERRORS[errorId])
this.name = 'File System Error'
this.errorId = errorId
this.cause = FS_ERRORS[errorId]
this.code = error.code
this.messageStack = [FS_ACTIONS[action], customMessage || FS_ERRORS[errorId]]
this.error = error
}

getInfo() {
Expand Down
Loading

0 comments on commit 90ccd57

Please sign in to comment.