Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: pull languages cmd #129

Merged
merged 10 commits into from
Nov 5, 2024
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,7 @@ dist
# CLI generated files
components.*.json
presets.*.json
storyblok-component-types.d.ts
storyblok-component-types.d.ts

# storyblok folder
.storyblok/
14 changes: 14 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@
"env": {
"STUB": "true"
}
},
{
"type": "node",
"request": "launch",
"name": "Debug Pull languages",
"program": "${workspaceFolder}/dist/index.mjs",
"args": ["pull-languages", "--space", "2950182323", "--path", ".storyblok"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"sourceMaps": true,
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"env": {
"STUB": "true"
}
}
]
}
3 changes: 3 additions & 0 deletions src/commands/login/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,11 @@ export const loginCommand = program
region: RegionCode
}) => {
konsola.title(` ${commands.LOGIN} `, colorPalette.LOGIN)
// Global options
const verbose = program.opts().verbose
// Command options
const { token, region } = options

if (!isRegion(region)) {
handleError(new CommandError(`The provided region: ${region} is not valid. Please use one of the following values: ${Object.values(regions).join(' | ')}`))
}
Expand Down
92 changes: 92 additions & 0 deletions src/commands/pull-languages/actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { vol } from 'memfs'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { pullLanguages, saveLanguagesToFile } from './actions'

const handlers = [
http.get('https://api.storyblok.com/v1/spaces/12345', async ({ request }) => {
const token = request.headers.get('Authorization')
if (token === 'valid-token') {
return HttpResponse.json({
space: {
default_lang_name: 'en',
languages: [
{
code: 'ca',
name: 'Catalan',
},
{
code: 'fr',
name: 'French',
},
],
},
})
}
return new HttpResponse('Unauthorized', { status: 401 })
}),
]

const server = setupServer(...handlers)

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

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

vi.mock('node:fs')
vi.mock('node:fs/promises')

describe('pull languages actions', () => {
beforeEach(() => {
vi.clearAllMocks()
vol.reset()
})

describe('pullLanguages', () => {
it('should pull languages successfully with a valid token', async () => {
const mockResponse = {
default_lang_name: 'en',
languages: [
{
code: 'ca',
name: 'Catalan',
},
{
code: 'fr',
name: 'French',
},
],
}
const result = await pullLanguages('12345', 'valid-token', 'eu')
expect(result).toEqual(mockResponse)
})
})
it('should throw an masked error for invalid token', async () => {
await expect(pullLanguages('12345', 'invalid-token', 'eu')).rejects.toThrow(
new Error(`The user is not authorized to access the API`),
)
})

describe('saveLanguagesToFile', () => {
it('should save languages to a json file with space number', async () => {
const mockResponse = {
default_lang_name: 'en',
languages: [
{
code: 'ca',
name: 'Catalan',
},
{
code: 'fr',
name: 'French',
},
],
}
await saveLanguagesToFile('12345', mockResponse, '/temp')
const content = vol.readFileSync('/temp/languages.12345.json', 'utf8')
expect(content).toBe(JSON.stringify(mockResponse, null, 2))
})
})
})
65 changes: 65 additions & 0 deletions src/commands/pull-languages/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { access, constants, mkdir, writeFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'

import { handleAPIError, handleFileSystemError } from '../../utils'
import { ofetch } from 'ofetch'
import { regionsDomain } from '../../constants'

export interface SpaceInternationalizationOptions {
languages: SpaceLanguage[]
default_lang_name: string
}
export interface SpaceLanguage {
code: string
name: string
}

export const pullLanguages = async (space: string, token: string, region: string): Promise<SpaceInternationalizationOptions | undefined> => {
try {
const response = await ofetch(`https://${regionsDomain[region]}/v1/spaces/${space}`, {
headers: {
Authorization: token,
},
})
return {
alvarosabu marked this conversation as resolved.
Show resolved Hide resolved
default_lang_name: response.space.default_lang_name,
languages: response.space.languages,
}
}
catch (error) {
handleAPIError('pull_languages', error as Error)
}
}

export const saveLanguagesToFile = async (space: string, internationalizationOptions: SpaceInternationalizationOptions, path?: string) => {
try {
const data = JSON.stringify(internationalizationOptions, null, 2)
const filename = `languages.${space}.json`
const resolvedPath = path ? resolve(process.cwd(), path) : process.cwd()
const filePath = join(resolvedPath, filename)

// Check if the path exists, and create it if it doesn't
try {
await access(resolvedPath, constants.F_OK)
}
catch {
try {
await mkdir(resolvedPath, { recursive: true })
}
catch (mkdirError) {
handleFileSystemError('mkdir', mkdirError as Error)
return // Exit early if the directory creation fails
}
}

try {
await writeFile(filePath, data, { mode: 0o600 })
}
catch (writeError) {
handleFileSystemError('write', writeError as Error)
}
}
catch (error) {
handleFileSystemError('write', error as Error)
}
edodusi marked this conversation as resolved.
Show resolved Hide resolved
}
164 changes: 164 additions & 0 deletions src/commands/pull-languages/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import chalk from 'chalk'
import { pullLanguagesCommand } from '.'
import { session } from '../../session'
import { CommandError, konsola } from './../../utils/'
import { pullLanguages, saveLanguagesToFile } from './actions'
import { colorPalette } from '../../constants'

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

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

// Mocking the session module
vi.mock('../../session', () => {
let _cache: Record<string, any> | null = null
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(),
warn: vi.fn(),
error: vi.fn(),
},
handleError: (error: Error, header = false) => {
konsola.error(error, header)
// Optionally, prevent process.exit during tests
},
}
})

describe('pullLanguages', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.clearAllMocks()
// Reset the option values
pullLanguagesCommand._optionValues = {}
})

describe('default mode', () => {
it('should prompt the user if operation was sucessfull', async () => {
const mockResponse = {
default_lang_name: 'en',
languages: [
{
code: 'ca',
name: 'Catalan',
},
{
code: 'fr',
name: 'French',
},
],
}
session().state = {
isLoggedIn: true,
password: 'valid-token',
region: 'eu',
}

vi.mocked(pullLanguages).mockResolvedValue(mockResponse)
await pullLanguagesCommand.parseAsync(['node', 'test', '--space', '12345'])
expect(pullLanguages).toHaveBeenCalledWith('12345', 'valid-token', 'eu')
expect(saveLanguagesToFile).toHaveBeenCalledWith('12345', mockResponse, undefined)
expect(konsola.ok).toHaveBeenCalledWith(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(`languages.12345.json`)}`)
})

it('should throw an error if the user is not logged in', async () => {
session().state = {
isLoggedIn: false,
}
const mockError = new CommandError(`You are currently not logged in. Please login first to get your user info.`)
await pullLanguagesCommand.parseAsync(['node', 'test', '--space', '12345'])
expect(konsola.error).toHaveBeenCalledWith(mockError, false)
})

it('should throw an error if the space is not provided', async () => {
session().state = {
isLoggedIn: true,
password: 'valid-token',
region: 'eu',
}

const mockError = new CommandError(`Please provide the space as argument --space YOUR_SPACE_ID.`)

console.log(pullLanguagesCommand)
await pullLanguagesCommand.parseAsync(['node', 'test'])
expect(konsola.error).toHaveBeenCalledWith(mockError, false)
})

it('should prompt a warning the user if no languages are found', async () => {
const mockResponse = {
default_lang_name: 'en',
languages: [],
}
session().state = {
isLoggedIn: true,
password: 'valid-token',
region: 'eu',
}

vi.mocked(pullLanguages).mockResolvedValue(mockResponse)

await pullLanguagesCommand.parseAsync(['node', 'test', '--space', '24568'])
expect(konsola.warn).toHaveBeenCalledWith(`No languages found in the space 24568`)
})
})

describe('--path option', () => {
it('should save the file at the provided path', async () => {
const mockResponse = {
default_lang_name: 'en',
languages: [
{
code: 'ca',
name: 'Catalan',
},
{
code: 'fr',
name: 'French',
},
],
}
session().state = {
isLoggedIn: true,
password: 'valid-token',
region: 'eu',
}

vi.mocked(pullLanguages).mockResolvedValue(mockResponse)
await pullLanguagesCommand.parseAsync(['node', 'test', '--space', '12345', '--path', '/tmp'])
expect(pullLanguages).toHaveBeenCalledWith('12345', 'valid-token', 'eu')
expect(saveLanguagesToFile).toHaveBeenCalledWith('12345', mockResponse, '/tmp')
expect(konsola.ok).toHaveBeenCalledWith(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(`/tmp/languages.12345.json`)}`)
})
})
})
Loading