Skip to content

Commit

Permalink
Merge pull request #123 from storyblok/feature/user-cmd
Browse files Browse the repository at this point in the history
feat: user cmd
  • Loading branch information
alvarosabu authored Oct 29, 2024
2 parents fc747e9 + b97f819 commit 044ff92
Show file tree
Hide file tree
Showing 11 changed files with 237 additions and 10 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Run linters
name: Run Tests
on: [push]

env:
Expand Down
4 changes: 2 additions & 2 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@
{
"type": "node",
"request": "launch",
"name": "Debug test",
"name": "Debug user",
"program": "${workspaceFolder}/dist/index.mjs",
"args": ["test", "--verbose"],
"args": ["user"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"sourceMaps": true,
Expand Down
6 changes: 3 additions & 3 deletions src/commands/login/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import chalk from 'chalk'
import { input, password, select } from '@inquirer/prompts'
import type { RegionCode } from '../../constants'
import { commands, regionNames, regions, regionsDomain } from '../../constants'
import { colorPalette, commands, regionNames, regions, regionsDomain } from '../../constants'
import { getProgram } from '../../program'
import { CommandError, handleError, isRegion, konsola } from '../../utils'
import { loginWithEmailAndPassword, loginWithOtp, loginWithToken } from './actions'
Expand Down Expand Up @@ -40,7 +40,7 @@ export const loginCommand = program
token: string
region: RegionCode
}) => {
konsola.title(` ${commands.LOGIN} `, '#8556D3')
konsola.title(` ${commands.LOGIN} `, colorPalette.LOGIN)
const verbose = program.opts().verbose
const { token, region } = options
if (!isRegion(region)) {
Expand Down Expand Up @@ -122,7 +122,7 @@ export const loginCommand = program
updateSession(userEmail, response.access_token, userRegion)
}
await persistCredentials(regionsDomain[userRegion])
konsola.ok(`Successfully logged in with email ${chalk.hex('#45bfb9')(userEmail)}`)
konsola.ok(`Successfully logged in with email ${chalk.hex(colorPalette.PRIMARY)(userEmail)}`)
}
}
catch (error) {
Expand Down
54 changes: 54 additions & 0 deletions src/commands/user/actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import chalk from 'chalk'
import { getUser } from './actions'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'

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' })
}
return new HttpResponse('Unauthorized', { status: 401 })
}),
]

const server = setupServer(...handlers)

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))

afterEach(() => server.resetHandlers())
afterAll(() => server.close())

describe('user actions', () => {
beforeEach(() => {
vi.clearAllMocks()
})

describe('getUser', () => {
it('should get user successfully with a valid token', async () => {
const mockResponse = { data: 'user data' }
const result = await getUser('valid-token', 'eu')
expect(result).toEqual(mockResponse)
})
})

it('should throw an masked error for invalid token', async () => {
await expect(getUser('invalid-token', 'eu')).rejects.toThrow(
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 () => {
server.use(
http.get('https://api.storyblok.com/v1/users/me', () => {
return new HttpResponse(null, { status: 500 })
}),
)
await expect(getUser('any-token', 'eu')).rejects.toThrow(
'No response from server, please check if you are correctly connected to internet',
)
})
})
30 changes: 30 additions & 0 deletions src/commands/user/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { FetchError, ofetch } from 'ofetch'
import { regionsDomain } from '../../constants'
import chalk from 'chalk'
import { APIError, maskToken } from '../../utils'

export const getUser = async (token: string, region: string) => {
try {
return await ofetch(`https://${regionsDomain[region]}/v1/users/me`, {
headers: {
Authorization: token,
},
})
}
catch (error) {
if (error instanceof FetchError) {
const status = error.response?.status

switch (status) {
case 401:
throw new APIError('unauthorized', 'get_user', error, `The token provided ${chalk.bold(maskToken(token))} is invalid.
Please make sure you are using the correct token and try again.`)
default:
throw new APIError('network_error', 'get_user', error)
}
}
else {
throw new APIError('generic', 'get_user', error as Error)
}
}
}
104 changes: 104 additions & 0 deletions src/commands/user/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { userCommand } from './'
import { getUser } from './actions'
import { konsola } from '../../utils'
import { session } from '../../session'
import chalk from 'chalk'

vi.mock('./actions', () => ({
getUser: vi.fn(),
}))

vi.mock('../../creds', () => ({
isAuthorized: vi.fn(),
}))

// Mocking the session module
vi.mock('../../session', () => {
let _cache
const session = () => {
if (!_cache) {
_cache = {
state: {
isLoggedIn: false,
},
updateSession: vi.fn(),
persistCredentials: vi.fn(),
initializeSession: vi.fn(),
}
}
return _cache
}

return {
session,
}
})

vi.mock('../../utils', async () => {
const actualUtils = await vi.importActual('../../utils')
return {
...actualUtils,
konsola: {
ok: vi.fn(),
title: vi.fn(),
error: vi.fn(),
},
handleError: (error: Error, header = false) => {
konsola.error(error, header)
// Optionally, prevent process.exit during tests
},
}
})

describe('userCommand', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.clearAllMocks()
})

it('should show the user information', async () => {
const mockResponse = {
user: {
friendly_name: 'John Doe',
email: '[email protected]',
},
}
session().state = {
isLoggedIn: true,
password: 'valid-token',
region: 'eu',
}
getUser.mockResolvedValue(mockResponse)
await userCommand.parseAsync(['node', 'test'])

expect(getUser).toHaveBeenCalledWith('valid-token', 'eu')
expect(konsola.ok).toHaveBeenCalledWith(
`Hi ${chalk.bold('John Doe')}, you are currently logged in with ${chalk.hex('#45bfb9')(mockResponse.user.email)} on ${chalk.bold('eu')} region`,
)
})

it('should show an error if the user is not logged in', async () => {
session().state = {
isLoggedIn: false,
}
await userCommand.parseAsync(['node', 'test'])

expect(konsola.error).toHaveBeenCalledWith(new Error(`You are currently not logged in. Please login first to get your user info.`), false)
})

it('should show an error if the user information cannot be fetched', async () => {
session().state = {
isLoggedIn: true,
password: 'valid-token',
region: 'eu',
}

const mockError = new Error('Network error')

getUser.mockRejectedValue(mockError)

await userCommand.parseAsync(['node', 'test'])

expect(konsola.error).toHaveBeenCalledWith(mockError, true)
})
})
31 changes: 31 additions & 0 deletions src/commands/user/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import chalk from 'chalk'
import type { NetrcMachine } from '../../creds'
import { colorPalette, commands } from '../../constants'
import { getProgram } from '../../program'
import { CommandError, handleError, konsola } from '../../utils'
import { getUser } from './actions'
import { session } from '../../session'

const program = getProgram() // Get the shared singleton instance

export const userCommand = program
.command(commands.USER)
.description('Get the current user')
.action(async () => {
konsola.title(` ${commands.USER} `, colorPalette.USER)
const { state, initializeSession } = session()
await initializeSession()

if (!state.isLoggedIn) {
handleError(new CommandError(`You are currently not logged in. Please login first to get your user info.`))
return
}
try {
const { password, region } = state as NetrcMachine
const { user } = await getUser(password, region)
konsola.ok(`Hi ${chalk.bold(user.friendly_name)}, you are currently logged in with ${chalk.hex(colorPalette.PRIMARY)(user.email)} on ${chalk.bold(region)} region`)
}
catch (error) {
handleError(error as Error, true)
}
})
7 changes: 7 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
export const commands = {
LOGIN: 'login',
LOGOUT: 'logout',
USER: 'user',
} as const

export const colorPalette = {
PRIMARY: '#45bfb9',
LOGIN: '#8556D3',
USER: '#8BC34A', // Changed to a less saturated green color
} as const

export interface ReadonlyArray<T> {
Expand Down
6 changes: 3 additions & 3 deletions src/creds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { access, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { FileSystemError, handleFileSystemError, konsola } from './utils'
import chalk from 'chalk'
import { regionCodes } from './constants'
import { colorPalette, regionCodes } from './constants'

export interface NetrcMachine {
login: string
Expand Down Expand Up @@ -186,7 +186,7 @@ export const addNetrcEntry = async ({
mode: 0o600, // Set file permissions
})

konsola.ok(`Successfully added/updated entry for machine ${machineName} in ${chalk.hex('#45bfb9')(filePath)}`, true)
konsola.ok(`Successfully added/updated entry for machine ${machineName} in ${chalk.hex(colorPalette.PRIMARY)(filePath)}`, true)
}
catch (error) {
throw new FileSystemError('invalid_argument', 'write', error as NodeJS.ErrnoException, `Error adding/updating entry for machine ${machineName} in .netrc file`)
Expand Down Expand Up @@ -223,7 +223,7 @@ export const removeNetrcEntry = async (
mode: 0o600, // Set file permissions
})

konsola.ok(`Successfully removed entry from ${chalk.hex('#45bfb9')(filePath)}`, true)
konsola.ok(`Successfully removed entry from ${chalk.hex(colorPalette.PRIMARY)(filePath)}`, true)
}
}
catch (error: unknown) {
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { formatHeader, handleError, konsola } from './utils'
import { getProgram } from './program'
import './commands/login'
import './commands/logout'
import './commands/user'
import { loginWithToken } from './commands/login/actions'

dotenv.config() // This will load variables from .env into process.env
Expand Down Expand Up @@ -47,7 +48,6 @@ ${chalk.hex('#45bfb9')(' |/ ')}

try {
program.parse(process.argv)
konsola.br() // Add a line break
}
catch (error) {
handleError(error as Error)
Expand Down
1 change: 1 addition & 0 deletions src/utils/error/api-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const API_ACTIONS = {
login_with_token: 'Failed to log in with token',
login_with_otp: 'Failed to log in with email, password and otp',
login_email_password: 'Failed to log in with email and password',
get_user: 'Failed to get user',
} as const

export const API_ERRORS = {
Expand Down

0 comments on commit 044ff92

Please sign in to comment.