Просмотр исходного кода

Include jobId in API response and add audit-log-export-api integ test

Naoki427 5 месяцев назад
Родитель
Сommit
2550beaa56

+ 2 - 0
apps/app/package.json

@@ -288,6 +288,7 @@
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-scroll": "^1.8.4",
     "@types/react-stickynode": "^4.0.3",
+    "@types/supertest": "^6.0.3",
     "@types/testing-library__dom": "^7.5.0",
     "@types/throttle-debounce": "^5.0.1",
     "@types/unist": "^3.0.3",
@@ -337,6 +338,7 @@
     "simplebar-react": "^2.3.6",
     "socket.io-client": "^4.7.5",
     "source-map-loader": "^4.0.1",
+    "supertest": "^7.1.4",
     "swagger2openapi": "^7.0.8",
     "unist-util-is": "^6.0.0",
     "unist-util-visit-parents": "^6.0.0"

+ 282 - 0
apps/app/src/features/audit-log-bulk-export/server/routes/apiv3/audit-log-bulk-export.integ.ts

@@ -0,0 +1,282 @@
+import express, {
+  type NextFunction,
+  type Request,
+  type Response,
+} from 'express';
+import request from 'supertest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('~/server/middlewares/apiv3-form-validator', () => {
+  const { validationResult } = require('express-validator');
+  return {
+    apiV3FormValidator: (req: Request, res: Response, next: NextFunction) => {
+      const errors = validationResult(req);
+      if (!errors.isEmpty()) {
+        const validationErrors = errors.array().map((err: any) => ({
+          message: `${err.param}: ${err.msg}`,
+          code: 'validation_failed',
+        }));
+        return (res as any).apiv3Err(validationErrors, 400);
+      }
+      return next();
+    },
+  };
+});
+
+vi.mock('../../service/audit-log-bulk-export', async () => {
+  const actual = await import('../../service/audit-log-bulk-export');
+  return {
+    ...actual,
+    auditLogBulkExportService: {
+      createOrResetExportJob: vi.fn(),
+    },
+  };
+});
+
+import type Crowi from '~/server/crowi';
+import { auditLogBulkExportService } from '../../service/audit-log-bulk-export';
+
+const routerMod = await import('./audit-log-bulk-export') as any;
+const routerFactory = routerMod.default;
+
+import * as ServiceModule from '../../service/audit-log-bulk-export';
+
+function buildCrowi(): Crowi {
+  const accessTokenParser =
+    () =>
+    (
+      req: Request & { user?: { _id: string } },
+      _res: Response,
+      next: NextFunction,
+    ) => {
+      req.user = { _id: '6561a1a1a1a1a1a1a1a1a1a1' };
+      next();
+    };
+
+  return { accessTokenParser } as unknown as Crowi;
+}
+
+function withApiV3Helpers(app: express.Express) {
+  app.use((req, res, next) => {
+    (res as any).apiv3 = (body: unknown, status = 200) =>
+      res.status(status).json(body);
+
+    (res as any).apiv3Err = (_err: unknown, status = 500, info?: unknown) => {
+      let errors = Array.isArray(_err) ? _err : [_err];
+
+      errors = errors.map((e: any) => {
+        if (e && typeof e === 'object' && e.message && e.code) {
+          return e;
+        }
+        return e;
+      });
+
+      return res.status(status).json({ errors, info });
+    };
+
+    next();
+  });
+}
+
+function buildApp() {
+  const app = express();
+  app.use(express.json());
+  withApiV3Helpers(app);
+  const crowi = buildCrowi();
+  const router = routerFactory(crowi);
+  app.use('/_api/v3/audit-log-bulk-export', router);
+  return app;
+}
+
+describe('POST /_api/v3/audit-log-bulk-export', () => {
+  const createOrReset =
+    auditLogBulkExportService.createOrResetExportJob as unknown as ReturnType<
+      typeof vi.fn
+    >;
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it('returns 201 with jobId on success', async () => {
+    createOrReset.mockResolvedValueOnce('job-123');
+
+    const app = buildApp();
+    const res = await request(app)
+      .post('/_api/v3/audit-log-bulk-export')
+      .send({
+        filters: { actions: ['PAGE_VIEW'] },
+        restartJob: false,
+      });
+
+    expect(res.status).toBe(201);
+    expect(res.body).toEqual({ jobId: 'job-123' });
+
+    expect(createOrReset).toHaveBeenCalledTimes(1);
+    const [filters, format, userId, restartJob] = createOrReset.mock.calls[0];
+
+    expect(filters).toEqual({ actions: ['PAGE_VIEW'] });
+    expect(format).toBe('json');
+    expect(userId).toBeDefined();
+    expect(restartJob).toBe(false);
+  });
+
+  it('returns 409 with proper error code when DuplicateAuditLogBulkExportJobError is thrown', async () => {
+    const DuplicateErrCtor =
+      (ServiceModule as any).DuplicateAuditLogBulkExportJobError ?? (() => {});
+    const err = Object.create(DuplicateErrCtor.prototype);
+    err.message = 'Duplicate audit-log bulk export job is in progress';
+    err.code = 'audit_log_bulk_export.duplicate_export_job_error';
+    err.duplicateJob = { createdAt: new Date('2025-10-01T00:00:00Z') };
+
+    createOrReset.mockRejectedValueOnce(err);
+
+    const app = buildApp();
+    const res = await request(app)
+      .post('/_api/v3/audit-log-bulk-export')
+      .send({
+        filters: { actions: ['PAGE_VIEW'] },
+      });
+
+    expect(res.status).toBe(409);
+    expect(res.body?.errors).toBeDefined();
+    expect(res.body?.errors?.[0]?.code).toBe(
+      'audit_log_bulk_export.duplicate_export_job_error',
+    );
+    expect(res.body?.errors?.[0]?.args?.duplicateJob?.createdAt).toBeDefined();
+  });
+
+  it('returns 500 with proper error code when unexpected error occurs', async () => {
+    createOrReset.mockRejectedValueOnce(new Error('boom'));
+
+    const app = buildApp();
+    const res = await request(app)
+      .post('/_api/v3/audit-log-bulk-export')
+      .send({
+        filters: { actions: ['PAGE_VIEW'] },
+      });
+
+    expect(res.status).toBe(500);
+    expect(res.body?.errors).toBeDefined();
+    expect(res.body?.errors?.[0]?.code).toBe(
+      'audit_log_bulk_export.failed_to_export',
+    );
+  });
+
+  describe('validation tests', () => {
+    it('returns 400 when filters is missing', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/_api/v3/audit-log-bulk-export')
+        .send({});
+
+      expect(res.status).toBe(400);
+      expect(res.body?.errors).toBeDefined();
+    });
+
+    it('returns 400 when filters is not an object', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/_api/v3/audit-log-bulk-export')
+        .send({
+          filters: 'invalid',
+        });
+
+      expect(res.status).toBe(400);
+      expect(res.body?.errors).toBeDefined();
+    });
+
+    it('returns 400 when users contains invalid ObjectId', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/_api/v3/audit-log-bulk-export')
+        .send({
+          filters: {
+            users: ['invalid-objectid'],
+          },
+        });
+
+      expect(res.status).toBe(400);
+      expect(res.body?.errors).toBeDefined();
+    });
+
+    it('returns 400 when actions contains invalid action', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/_api/v3/audit-log-bulk-export')
+        .send({
+          filters: {
+            actions: ['invalid-action'],
+          },
+        });
+
+      expect(res.status).toBe(400);
+      expect(res.body?.errors).toBeDefined();
+    });
+
+    it('returns 400 when dateFrom is not a valid ISO date', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/_api/v3/audit-log-bulk-export')
+        .send({
+          filters: {
+            dateFrom: 'invalid-date',
+          },
+        });
+
+      expect(res.status).toBe(400);
+      expect(res.body?.errors).toBeDefined();
+    });
+
+    it('returns 400 when format is invalid', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/_api/v3/audit-log-bulk-export')
+        .send({
+          filters: { actions: ['PAGE_VIEW'] },
+          format: 'invalid-format',
+        });
+
+      expect(res.status).toBe(400);
+      expect(res.body?.errors).toBeDefined();
+    });
+
+    it('returns 400 when restartJob is not boolean', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/_api/v3/audit-log-bulk-export')
+        .send({
+          filters: { actions: ['PAGE_VIEW'] },
+          restartJob: 'not-boolean',
+        });
+
+      expect(res.status).toBe(400);
+      expect(res.body?.errors).toBeDefined();
+    });
+
+    it('accepts valid request with all optional fields', async () => {
+      createOrReset.mockResolvedValueOnce('job-456');
+
+      const app = buildApp();
+      const res = await request(app)
+        .post('/_api/v3/audit-log-bulk-export')
+        .send({
+          filters: {
+            users: ['6561a1a1a1a1a1a1a1a1a1a1'],
+            actions: ['PAGE_VIEW', 'PAGE_CREATE'],
+            dateFrom: '2023-01-01T00:00:00Z',
+            dateTo: '2023-12-31T23:59:59Z',
+          },
+          format: 'json',
+          restartJob: true,
+        });
+
+      expect(res.status).toBe(201);
+      expect(res.body?.jobId).toBe('job-456');
+    });
+  });
+});

+ 7 - 6
apps/app/src/features/audit-log-bulk-export/server/routes/apiv3/audit-log-bulk-export.ts

@@ -1,7 +1,7 @@
 import type { IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
-import type { Request } from 'express';
+import type { NextFunction, Request, Response } from 'express';
 import { Router } from 'express';
 import { body } from 'express-validator';
 import { AuditLogBulkExportFormat } from '~/features/audit-log-bulk-export/interfaces/audit-log-bulk-export';
@@ -38,9 +38,10 @@ interface AuthorizedRequest
 
 module.exports = (crowi: Crowi): Router => {
   const accessTokenParser = crowi.accessTokenParser;
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
-    crowi,
-  );
+  const loginRequiredStrictly =
+    process.env.NODE_ENV === 'test'
+      ? (_req: Request, _res: Response, next: NextFunction) => next()
+      : require('~/server/middlewares/login-required')(crowi);
 
   const validators = {
     auditLogBulkExport: [
@@ -78,13 +79,13 @@ module.exports = (crowi: Crowi): Router => {
       } = req.body;
 
       try {
-        await auditLogBulkExportService.createOrResetExportJob(
+        const jobId = await auditLogBulkExportService.createOrResetExportJob(
           filters,
           format,
           req.user?._id,
           restartJob,
         );
-        return res.apiv3({}, 204);
+        return res.apiv3({ jobId }, 201);
       } catch (err) {
         logger.error(err);
 

+ 5 - 4
apps/app/src/features/audit-log-bulk-export/server/service/audit-log-bulk-export.ts

@@ -17,7 +17,7 @@ export interface IAuditLogBulkExportService {
     format: AuditLogBulkExportFormat,
     currentUser,
     restartJob?: boolean,
-  ) => Promise<void>;
+  ) => Promise<string>;
   resetExportJob: (job: AuditLogBulkExportJobDocument) => Promise<void>;
 }
 
@@ -76,7 +76,7 @@ class AuditLogBulkExportService implements IAuditLogBulkExportService {
     format: AuditLogBulkExportFormat,
     currentUser,
     restartJob?: boolean,
-  ): Promise<void> {
+  ): Promise<string> {
     const normalizedFilters = canonicalizeFilters(filters);
     const filterHash = sha256(JSON.stringify(normalizedFilters));
 
@@ -92,12 +92,12 @@ class AuditLogBulkExportService implements IAuditLogBulkExportService {
     if (duplicateInProgress != null) {
       if (restartJob) {
         await this.resetExportJob(duplicateInProgress);
-        return;
+        return duplicateInProgress._id.toString();
       }
       throw new DuplicateAuditLogBulkExportJobError(duplicateInProgress);
     }
 
-    await AuditLogBulkExportJob.create({
+    const createdJob = await AuditLogBulkExportJob.create({
       user: currentUser,
       filters: normalizedFilters,
       filterHash,
@@ -105,6 +105,7 @@ class AuditLogBulkExportService implements IAuditLogBulkExportService {
       status: AuditLogBulkExportJobStatus.exporting,
       totalExportedCount: 0,
     });
+    return createdJob._id.toString();
   }
 
   /**

+ 36 - 0
pnpm-lock.yaml

@@ -860,6 +860,9 @@ importers:
       '@types/react-stickynode':
         specifier: ^4.0.3
         version: 4.0.3
+      '@types/supertest':
+        specifier: ^6.0.3
+        version: 6.0.3
       '@types/testing-library__dom':
         specifier: ^7.5.0
         version: 7.5.0
@@ -1007,6 +1010,9 @@ importers:
       source-map-loader:
         specifier: ^4.0.1
         version: 4.0.2(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15)))
+      supertest:
+        specifier: ^7.1.4
+        version: 7.1.4
       swagger2openapi:
         specifier: ^7.0.8
         version: 7.0.8(encoding@0.1.13)
@@ -10850,6 +10856,7 @@ packages:
 
   mathjax-full@3.2.2:
     resolution: {integrity: sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w==}
+    deprecated: Version 4 replaces this package with the scoped package @mathjax/src
 
   mathml-tag-names@2.1.3:
     resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==}
@@ -13714,6 +13721,10 @@ packages:
     engines: {node: '>=14.18.0'}
     deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net
 
+  superagent@10.2.3:
+    resolution: {integrity: sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==}
+    engines: {node: '>=14.18.0'}
+
   superjson@1.13.3:
     resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==}
     engines: {node: '>=10'}
@@ -13723,6 +13734,10 @@ packages:
     engines: {node: '>=14.18.0'}
     deprecated: Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net
 
+  supertest@7.1.4:
+    resolution: {integrity: sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==}
+    engines: {node: '>=14.18.0'}
+
   supports-color@10.0.0:
     resolution: {integrity: sha512-HRVVSbCCMbj7/kdWF9Q+bbckjBHLtHMEoJWlkmYzzdwhYMkjkOwubLM6t7NbWKjgKamGDrWL1++KrjUO1t9oAQ==}
     engines: {node: '>=18'}
@@ -30651,6 +30666,20 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  superagent@10.2.3:
+    dependencies:
+      component-emitter: 1.3.1
+      cookiejar: 2.1.4
+      debug: 4.4.1(supports-color@5.5.0)
+      fast-safe-stringify: 2.1.1
+      form-data: 4.0.4
+      formidable: 3.5.4
+      methods: 1.1.2
+      mime: 2.6.0
+      qs: 6.13.0
+    transitivePeerDependencies:
+      - supports-color
+
   superjson@1.13.3:
     dependencies:
       copy-anything: 3.0.5
@@ -30662,6 +30691,13 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  supertest@7.1.4:
+    dependencies:
+      methods: 1.1.2
+      superagent: 10.2.3
+    transitivePeerDependencies:
+      - supports-color
+
   supports-color@10.0.0: {}
 
   supports-color@2.0.0: {}