diff --git a/apps/server-nestjs/src/modules/observability/observability-client.service.spec.ts b/apps/server-nestjs/src/modules/observability/observability-client.service.spec.ts new file mode 100644 index 0000000000..482215e516 --- /dev/null +++ b/apps/server-nestjs/src/modules/observability/observability-client.service.spec.ts @@ -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 + getGroupRepos: ReturnType + createGroupRepo: ReturnType + getFile: ReturnType + generateCreateOrUpdateAction: ReturnType + maybeCreateCommit: ReturnType + } + + 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 = {} + 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') + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/observability/observability-plugin.service.spec.ts b/apps/server-nestjs/src/modules/observability/observability-plugin.service.spec.ts new file mode 100644 index 0000000000..4fb42bed3e --- /dev/null +++ b/apps/server-nestjs/src/modules/observability/observability-plugin.service.spec.ts @@ -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 + let config: DeepMockProxy> + + beforeEach(async () => { + datastore = mockDeep() + config = mockDeep>({ + 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 }, + }) + }) +})