Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import type { ProjectSchema } from '@gitbeaker/core'
import type { CondensedProjectSchemaWith } from '../gitlab/gitlab-client.service'
import { Test } from '@nestjs/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { GitlabClientService } from '../gitlab/gitlab-client.service'
import { makeProjectSchema } from '../gitlab/gitlab-testing.utils'
import { ObservabilityClientService } from './observability-client.service'
import { observabilityYamlInitData } from './observability.utils'

describe('observabilityClientService', () => {
let service: ObservabilityClientService
let gitlab: {
getOrCreateGroupByPath: ReturnType<typeof vi.fn>
getGroupRepos: ReturnType<typeof vi.fn>
createGroupRepo: ReturnType<typeof vi.fn>
getFile: ReturnType<typeof vi.fn>
generateCreateOrUpdateAction: ReturnType<typeof vi.fn>
maybeCreateCommit: ReturnType<typeof vi.fn>
}

const repo = makeProjectSchema({ name: 'values' }) as unknown as CondensedProjectSchemaWith<'id'>

beforeEach(async () => {
gitlab = {
getOrCreateGroupByPath: vi.fn(),
getGroupRepos: vi.fn(),
createGroupRepo: vi.fn(),
getFile: vi.fn(),
generateCreateOrUpdateAction: vi.fn(),
maybeCreateCommit: vi.fn(),
}

const moduleRef = await Test.createTestingModule({
providers: [
ObservabilityClientService,
{ provide: GitlabClientService, useValue: gitlab },
],
}).compile()

service = moduleRef.get(ObservabilityClientService)
})

describe('getOrCreateValuesRepo', () => {
it('returns the existing repo when the group already contains it', async () => {
gitlab.getOrCreateGroupByPath.mockResolvedValue({ id: 7 })
gitlab.getGroupRepos.mockImplementation(async function* () {
yield { id: 9, name: 'other' }
yield { id: 12, name: 'observability' }
})

await expect(service.getOrCreateValuesRepo()).resolves.toMatchObject({ id: 12 })
expect(gitlab.createGroupRepo).not.toHaveBeenCalled()
})

it('creates the repo when absent from the group', async () => {
gitlab.getOrCreateGroupByPath.mockResolvedValue({ id: 7 })
gitlab.getGroupRepos.mockImplementation(async function* () {})
gitlab.createGroupRepo.mockResolvedValue({ id: 99, name: 'observability' } as ProjectSchema)

await expect(service.getOrCreateValuesRepo()).resolves.toMatchObject({ id: 99 })
expect(gitlab.createGroupRepo).toHaveBeenCalledWith(7, 'observability')
})
})

describe('getValuesFile', () => {
it('falls back to a fresh init payload when the file is absent', async () => {
gitlab.getFile.mockResolvedValue(undefined)

const data = await service.getValuesFile(repo)
expect(data).toEqual({ global: { tenants: {} } })
// must be a clone: mutating the result must not corrupt the shared init constant
data.global!.tenants!.x = {}

Check notice on line 72 in apps/server-nestjs/src/modules/observability/observability-client.service.spec.ts

View check run for this annotation

cloud-pi-native-sonarqube / SonarQube Code Analysis

apps/server-nestjs/src/modules/observability/observability-client.service.spec.ts#L72

This assertion is unnecessary since it does not change the type of the expression.
expect(observabilityYamlInitData.global.tenants).toEqual({})
})

it('parses and validates base64 yaml content', async () => {
const yaml = 'global:\n projects:\n pid-1:\n projectName: p\n projectRepository:\n url: https://r\n path: .\n envs: {}\n'
gitlab.getFile.mockResolvedValue({
content: Buffer.from(yaml).toString('base64'),
})

const data = await service.getValuesFile(repo)
expect(data.global?.projects?.['pid-1']).toMatchObject({ projectName: 'p' })
})

it('rejects schema-invalid yaml (zod guard)', async () => {
gitlab.getFile.mockResolvedValue({
content: Buffer.from('global: 42\n').toString('base64'),
})

await expect(service.getValuesFile(repo)).rejects.toThrow()
})
})

describe('updateProjectConfig', () => {
it('skips the commit when the stored value is equal to the desired one (idempotent re-run)', async () => {
gitlab.getFile.mockResolvedValue({
content: Buffer.from(
`global:\n projects:\n pid:\n projectName: p\n projectRepository:\n url: https://r\n path: .\n envs: {}\n`,
).toString('base64'),
})

await service.updateProjectConfig(
repo,
{ id: 'pid', slug: 'p' },
{ projectName: 'p', projectRepository: { url: 'https://r', path: '.' }, envs: {} },
)
expect(gitlab.maybeCreateCommit).not.toHaveBeenCalled()
})

it('commits when the value differs and preserves sibling projects', async () => {
gitlab.getFile.mockResolvedValue({
content: Buffer.from(
'global:\n projects:\n other-id:\n projectName: other\n projectRepository:\n url: https://r2\n path: .\n envs: {}\n',
).toString('base64'),
})
gitlab.generateCreateOrUpdateAction.mockResolvedValue({ action: 'create', content: 'YAMLCONTENT' })

await service.updateProjectConfig(
repo,
{ id: 'new-id', slug: 'new' },
{ projectName: 'new', projectRepository: { url: 'https://r3', path: '.' }, envs: {} },
)

expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1)
// the service passes the merged yaml as the content arg to generateCreateOrUpdateAction
const contentArg = vi.mocked(gitlab.generateCreateOrUpdateAction).mock.calls[0][3] as string
expect(contentArg).toContain('other-id')
expect(contentArg).toContain('new-id')
})
})

describe('deleteProjectConfig', () => {
it('is a no-op when the project is not in the values file', async () => {
gitlab.getFile.mockResolvedValue({
content: Buffer.from('global:\n tenants: {}\n').toString('base64'),
})

await service.deleteProjectConfig(repo, { id: 'ghost', slug: 'g', name: 'g' })
expect(gitlab.maybeCreateCommit).not.toHaveBeenCalled()
})

it('removes only the target project and commits', async () => {
gitlab.getFile.mockResolvedValue({
content: Buffer.from(
'global:\n projects:\n keep:\n projectName: k\n projectRepository:\n url: https://r\n path: .\n envs: {}\n drop:\n projectName: d\n projectRepository:\n url: https://r\n path: .\n envs: {}\n',
).toString('base64'),
})
gitlab.generateCreateOrUpdateAction.mockResolvedValue({ action: 'update', content: 'YAMLCONTENT' })

await service.deleteProjectConfig(repo, { id: 'drop', slug: 'd', name: 'd' })

expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1)
const contentArg = vi.mocked(gitlab.generateCreateOrUpdateAction).mock.calls[0][3] as string
expect(contentArg).toContain('keep')
expect(contentArg).not.toContain('drop')
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { ConfigType } from '@nestjs/config'
import type { DeepMockProxy } from 'vitest-mock-extended'
import { faker } from '@faker-js/faker'
import { Test } from '@nestjs/testing'
import { beforeEach, describe, expect, it } from 'vitest'
import { mockDeep } from 'vitest-mock-extended'
import { observabilityConfigFactory } from '../../config/observability.config'
import { ObservabilityDatastoreService } from './observability-datastore.service'
import { ObservabilityPluginService } from './observability-plugin.service'
import { makeProject } from './observability-testing.utils'

describe('observabilityPluginService', () => {
let service: ObservabilityPluginService
let datastore: DeepMockProxy<ObservabilityDatastoreService>
let config: DeepMockProxy<ConfigType<typeof observabilityConfigFactory>>

beforeEach(async () => {
datastore = mockDeep<ObservabilityDatastoreService>()
config = mockDeep<ConfigType<typeof observabilityConfigFactory>>({
grafanaUrl: 'https://grafana.test',
chartVersion: '0.1.7',
})

const moduleRef = await Test.createTestingModule({
providers: [
ObservabilityPluginService,
{ provide: ObservabilityDatastoreService, useValue: datastore },
{ provide: observabilityConfigFactory.KEY, useValue: config },
],
}).compile()

service = moduleRef.get(ObservabilityPluginService)
})

it('throws when the project does not exist', async () => {
datastore.getProjectForInfos.mockResolvedValue(null)

await expect(service.infos('missing-id')).rejects.toThrow('Project not found')
})

it('advertises no dashboard urls when the project has no environments', async () => {
datastore.getProjectForInfos.mockResolvedValue(makeProject({ environments: [] }))

const infos = await service.infos('project-id')
const urls = infos.to()
expect(urls).toEqual([])
})

it('exposes an hprod url only for non-prod stages', async () => {
datastore.getProjectForInfos.mockResolvedValue(makeProject({
slug: 'myproj',
// stage names other than PROD count as hprod
environments: [{ id: faker.string.uuid(), name: faker.string.alphanumeric(8), stage: { name: 'dev' } }],
}))

const infos = await service.infos('project-id')
expect(infos.to()).toHaveLength(1)
expect(infos.to()[0]).toMatchObject({
to: 'https://grafana.test/hprod-myproj',
description: 'Hors production',
})
})

it('exposes a prod url only when a prod-stage environment exists', async () => {
datastore.getProjectForInfos.mockResolvedValue(makeProject({
slug: 'myproj',
environments: [{ id: faker.string.uuid(), name: faker.string.alphanumeric(8), stage: { name: 'prod' } }],
}))

const infos = await service.infos('project-id')
expect(infos.to()).toHaveLength(1)
expect(infos.to()[0]).toMatchObject({ to: 'https://grafana.test/prod-myproj' })
})

it('exposes both urls when both environment kinds exist', async () => {
datastore.getProjectForInfos.mockResolvedValue(makeProject({
slug: 'full',
environments: [
{ id: faker.string.uuid(), name: faker.string.alphanumeric(8), stage: { name: 'prod' } },
{ id: faker.string.uuid(), name: faker.string.alphanumeric(8), stage: { name: 'hprod' } },
],
}))

const infos = await service.infos('project-id')
expect(infos.to().map(u => u.description)).toEqual(['Hors production', 'Production'])
})

it('keeps the static plugin descriptor contract (title, image, switch config)', async () => {
datastore.getProjectForInfos.mockResolvedValue(makeProject())

const infos = await service.infos('project-id')
expect(infos.title).toBe('Grafana')
expect(infos.imgSrc).toBe('/img/grafana.png')
expect(infos.name).toBe('observability')
// global switch defaults to enabled, admin-writable
expect(infos.config.global[0]).toMatchObject({
key: 'enabled',
initialValue: 'enabled',
permissions: { admin: { read: true, write: true }, user: { read: true, write: false } },
})
// project instances text is read-only for everyone
expect(infos.config.project[0]?.permissions).toEqual({
admin: { read: false, write: false },
user: { read: false, write: false },
})
})
})