Skip to content

ClsPluginsHooksHost not initialized (tests) #374

@matiasgarcia

Description

@matiasgarcia

After upgrading nestjs-cls to version 6.0.1, I started encountering test failures in parts of my codebase that depend heavily on the @UseCls() decorator—especially in places where execution occurs outside the typical request lifecycle, like background jobs.

This happens because there’s no HTTP request triggering the CLS context—something @UseCls() expects by default. Even replacing the decorator with an explicit this.cls.run(...) block didn’t solve the problem.

Test code

import { createMock } from '@golevelup/ts-jest';
import { BaseJobRunner } from './base-job-runner.service';
import { JobMetrics } from '@shared/apm/metrics';
import { CustomClsModule } from '@cls';
import {
  ClsPluginTransactional,
  NoOpTransactionalAdapter,
} from '@nestjs-cls/transactional';
import { Test } from '@nestjs/testing';

describe('BaseJobRunnerService', () => {
  class FakeJobRunner extends BaseJobRunner {
    async execute(fail: boolean) {
      if (fail) throw new Error('test');
    }
  }

  async function buildRunner() {
    const app = await Test.createTestingModule({
      imports: [
        CustomClsModule.register({
          clsTransaction: new ClsPluginTransactional({
            adapter: new NoOpTransactionalAdapter({
              tx: createMock(),
            }),
          }),
        }),
      ],
      providers: [FakeJobRunner],
    }).compile();
    return app.get(FakeJobRunner);
  }

  it('should report as nok if the job fails', async () => {
    await buildRunner().then((jobRunner) => jobRunner.run(true));
    // assert
  });
});

CustomClsModule

export class CustomClsModule {
  static register(
    options: { clsTransaction?: ClsPluginTransactional } = {},
  ): Nest.DynamicModule {
    const clsTransaction =
      typeof options.clsTransaction === 'undefined'
        ? new ClsPluginTransactional({
            imports: [TypeOrmModule],
            adapter: new TransactionalAdapterTypeOrm({
              dataSourceToken: getDataSourceToken(),
            }),
            enableTransactionProxy: true,
          })
        : options.clsTransaction;

    return {
      module: CustomClsModule,
      global: true,
      imports: [
        ClsModule.forRoot({
          global: true,
          middleware: {
            mount: true,
            setup: (cls: CustomClsService, request: Request) => {
              const storeId = Number(request.get('x-store-id'));
              if (!isNaN(storeId)) cls.set('storeId', storeId);
              cls.set('reqId', randomUUID());
            },
          },
          plugins: [clsTransaction],
        }),
      ],
      providers: [
        {
          provide: CustomClsService,
          useExisting: ClsService,
        },
      ],
      exports: [CustomClsService, ClsLogger],
    };
  }
}

BaseJobRunner class

export abstract class BaseJobRunner<T extends any[] = any[]> {
  protected readonly logger: Logger;

  constructor(
    private readonly name: string,
    protected readonly cls: CustomClsService,
  ) {
    this.logger = new Logger(name);
  }

  @UseCls()
  public async run(...args: T): Promise<void> {
    await this.cls.run(async () => {
      const startDate = new Date();
      const start = startDate.getTime();
      let result: 'ok' | 'nok' = 'ok';

      this.logger.log(
        new Log({
          message: `Starting job ${this.name}`,
          data: { startDate },
        }),
      );

      try {
        await this.execute(...args);
      } catch (error: any) {
        result = 'nok';

        this.logger.error(
          new ErrorLog({ error, message: `error executing job ${this.name}` }),
        );
      } finally {
        const endDate = new Date();
        const elapsed = endDate.getTime() - start;

        // report result to metrics

        this.logger.log(
          new Log({
            message: `Finishing job ${this.name}`,
            data: { startDate, endDate },
          }),
        );
      }
    });
  }

  protected abstract execute(...args: T): Promise<void>;
}

Error trace

  ● BaseJobRunnerService › should report as ok if the job succeeds

    ClsPluginsHooksHost not initialized

      29 |   it('should report as ok if the job succeeds', async () => {
      30 |     const jobRunner = new FakeJobRunner('test', createMock<ClsService>());
    > 31 |     await jobRunner.run(false);
         |                     ^
      32 |    ...
      33 |       
      34 |       

      at Function.getInstance (../node_modules/nestjs-cls/dist/src/lib/plugin/cls-plugin-hooks-host.js:27:19)
      at Object.apply (../node_modules/nestjs-cls/dist/src/lib/cls-initializers/use-cls.decorator.js:20:81)
      at Object.<anonymous> (shared/cron-job/base-job-runner.service.spec.ts:31:21)

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentationsolvedThe question was answered or the problem was solved

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions