add tests
This commit is contained in:
272
apps/api/src/domain/policy/FeatureAvailabilityGuard.test.ts
Normal file
272
apps/api/src/domain/policy/FeatureAvailabilityGuard.test.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ExecutionContext, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FeatureAvailabilityGuard, inferActionTypeFromHttpMethod } from './FeatureAvailabilityGuard';
|
||||
import { PolicyService } from './PolicyService';
|
||||
import { FEATURE_AVAILABILITY_METADATA_KEY, FeatureAvailabilityMetadata } from './RequireCapability';
|
||||
|
||||
class MockReflector {
|
||||
getAllAndOverride = vi.fn();
|
||||
}
|
||||
|
||||
class MockPolicyService {
|
||||
getSnapshot = vi.fn();
|
||||
}
|
||||
|
||||
describe('FeatureAvailabilityGuard', () => {
|
||||
let guard: FeatureAvailabilityGuard;
|
||||
let reflector: MockReflector;
|
||||
let policyService: MockPolicyService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
FeatureAvailabilityGuard,
|
||||
{
|
||||
provide: Reflector,
|
||||
useClass: MockReflector,
|
||||
},
|
||||
{
|
||||
provide: PolicyService,
|
||||
useClass: MockPolicyService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = module.get<FeatureAvailabilityGuard>(FeatureAvailabilityGuard);
|
||||
reflector = module.get<Reflector>(Reflector) as unknown as MockReflector;
|
||||
policyService = module.get<PolicyService>(PolicyService) as unknown as MockPolicyService;
|
||||
});
|
||||
|
||||
describe('canActivate', () => {
|
||||
it('should return true when no metadata is found', async () => {
|
||||
const mockContext = {
|
||||
getHandler: () => () => {},
|
||||
getClass: () => class {},
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
reflector.getAllAndOverride.mockReturnValue(undefined);
|
||||
|
||||
const result = await guard.canActivate(mockContext);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(reflector.getAllAndOverride).toHaveBeenCalledWith(
|
||||
FEATURE_AVAILABILITY_METADATA_KEY,
|
||||
[mockContext.getHandler(), mockContext.getClass()]
|
||||
);
|
||||
});
|
||||
|
||||
it('should return true when feature is enabled', async () => {
|
||||
const mockContext = {
|
||||
getHandler: () => () => {},
|
||||
getClass: () => class {},
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
const metadata: FeatureAvailabilityMetadata = {
|
||||
capabilityKey: 'test-feature',
|
||||
actionType: 'view',
|
||||
};
|
||||
|
||||
reflector.getAllAndOverride.mockReturnValue(metadata);
|
||||
policyService.getSnapshot.mockResolvedValue({
|
||||
policyVersion: 1,
|
||||
operationalMode: 'normal',
|
||||
capabilities: { 'test-feature': 'enabled' },
|
||||
maintenanceAllowlist: { mutate: [], view: [] },
|
||||
loadedFrom: 'defaults',
|
||||
loadedAtIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const result = await guard.canActivate(mockContext);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw ServiceUnavailableException when in maintenance mode and not in allowlist', async () => {
|
||||
const mockContext = {
|
||||
getHandler: () => () => {},
|
||||
getClass: () => class {},
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
const metadata: FeatureAvailabilityMetadata = {
|
||||
capabilityKey: 'test-feature',
|
||||
actionType: 'mutate',
|
||||
};
|
||||
|
||||
reflector.getAllAndOverride.mockReturnValue(metadata);
|
||||
policyService.getSnapshot.mockResolvedValue({
|
||||
policyVersion: 1,
|
||||
operationalMode: 'maintenance',
|
||||
capabilities: { 'test-feature': 'enabled' },
|
||||
maintenanceAllowlist: { mutate: [], view: [] },
|
||||
loadedFrom: 'defaults',
|
||||
loadedAtIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(ServiceUnavailableException);
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow('Service temporarily unavailable');
|
||||
});
|
||||
|
||||
it('should return true when in maintenance mode but in allowlist', async () => {
|
||||
const mockContext = {
|
||||
getHandler: () => () => {},
|
||||
getClass: () => class {},
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
const metadata: FeatureAvailabilityMetadata = {
|
||||
capabilityKey: 'test-feature',
|
||||
actionType: 'mutate',
|
||||
};
|
||||
|
||||
reflector.getAllAndOverride.mockReturnValue(metadata);
|
||||
policyService.getSnapshot.mockResolvedValue({
|
||||
policyVersion: 1,
|
||||
operationalMode: 'maintenance',
|
||||
capabilities: { 'test-feature': 'enabled' },
|
||||
maintenanceAllowlist: { mutate: ['test-feature'], view: [] },
|
||||
loadedFrom: 'defaults',
|
||||
loadedAtIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const result = await guard.canActivate(mockContext);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when feature is disabled', async () => {
|
||||
const mockContext = {
|
||||
getHandler: () => () => {},
|
||||
getClass: () => class {},
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
const metadata: FeatureAvailabilityMetadata = {
|
||||
capabilityKey: 'test-feature',
|
||||
actionType: 'view',
|
||||
};
|
||||
|
||||
reflector.getAllAndOverride.mockReturnValue(metadata);
|
||||
policyService.getSnapshot.mockResolvedValue({
|
||||
policyVersion: 1,
|
||||
operationalMode: 'normal',
|
||||
capabilities: { 'test-feature': 'disabled' },
|
||||
maintenanceAllowlist: { mutate: [], view: [] },
|
||||
loadedFrom: 'defaults',
|
||||
loadedAtIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(NotFoundException);
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow('Not Found');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when feature is hidden', async () => {
|
||||
const mockContext = {
|
||||
getHandler: () => () => {},
|
||||
getClass: () => class {},
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
const metadata: FeatureAvailabilityMetadata = {
|
||||
capabilityKey: 'test-feature',
|
||||
actionType: 'view',
|
||||
};
|
||||
|
||||
reflector.getAllAndOverride.mockReturnValue(metadata);
|
||||
policyService.getSnapshot.mockResolvedValue({
|
||||
policyVersion: 1,
|
||||
operationalMode: 'normal',
|
||||
capabilities: { 'test-feature': 'hidden' },
|
||||
maintenanceAllowlist: { mutate: [], view: [] },
|
||||
loadedFrom: 'defaults',
|
||||
loadedAtIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(NotFoundException);
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow('Not Found');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when feature is coming_soon', async () => {
|
||||
const mockContext = {
|
||||
getHandler: () => () => {},
|
||||
getClass: () => class {},
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
const metadata: FeatureAvailabilityMetadata = {
|
||||
capabilityKey: 'test-feature',
|
||||
actionType: 'view',
|
||||
};
|
||||
|
||||
reflector.getAllAndOverride.mockReturnValue(metadata);
|
||||
policyService.getSnapshot.mockResolvedValue({
|
||||
policyVersion: 1,
|
||||
operationalMode: 'normal',
|
||||
capabilities: { 'test-feature': 'coming_soon' },
|
||||
maintenanceAllowlist: { mutate: [], view: [] },
|
||||
loadedFrom: 'defaults',
|
||||
loadedAtIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(NotFoundException);
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow('Not Found');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when feature is not configured', async () => {
|
||||
const mockContext = {
|
||||
getHandler: () => () => {},
|
||||
getClass: () => class {},
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
const metadata: FeatureAvailabilityMetadata = {
|
||||
capabilityKey: 'test-feature',
|
||||
actionType: 'view',
|
||||
};
|
||||
|
||||
reflector.getAllAndOverride.mockReturnValue(metadata);
|
||||
policyService.getSnapshot.mockResolvedValue({
|
||||
policyVersion: 1,
|
||||
operationalMode: 'normal',
|
||||
capabilities: {},
|
||||
maintenanceAllowlist: { mutate: [], view: [] },
|
||||
loadedFrom: 'defaults',
|
||||
loadedAtIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(NotFoundException);
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow('Not Found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inferActionTypeFromHttpMethod', () => {
|
||||
it('should return "view" for GET requests', () => {
|
||||
expect(inferActionTypeFromHttpMethod('GET')).toBe('view');
|
||||
});
|
||||
|
||||
it('should return "view" for HEAD requests', () => {
|
||||
expect(inferActionTypeFromHttpMethod('HEAD')).toBe('view');
|
||||
});
|
||||
|
||||
it('should return "view" for OPTIONS requests', () => {
|
||||
expect(inferActionTypeFromHttpMethod('OPTIONS')).toBe('view');
|
||||
});
|
||||
|
||||
it('should return "mutate" for POST requests', () => {
|
||||
expect(inferActionTypeFromHttpMethod('POST')).toBe('mutate');
|
||||
});
|
||||
|
||||
it('should return "mutate" for PUT requests', () => {
|
||||
expect(inferActionTypeFromHttpMethod('PUT')).toBe('mutate');
|
||||
});
|
||||
|
||||
it('should return "mutate" for PATCH requests', () => {
|
||||
expect(inferActionTypeFromHttpMethod('PATCH')).toBe('mutate');
|
||||
});
|
||||
|
||||
it('should return "mutate" for DELETE requests', () => {
|
||||
expect(inferActionTypeFromHttpMethod('DELETE')).toBe('mutate');
|
||||
});
|
||||
|
||||
it('should handle lowercase HTTP methods', () => {
|
||||
expect(inferActionTypeFromHttpMethod('get')).toBe('view');
|
||||
expect(inferActionTypeFromHttpMethod('post')).toBe('mutate');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import { Reflector } from '@nestjs/core';
|
||||
import { ActionType, FeatureState, PolicyService } from './PolicyService';
|
||||
import { FEATURE_AVAILABILITY_METADATA_KEY, FeatureAvailabilityMetadata } from './RequireCapability';
|
||||
|
||||
type Evaluation = { allow: true } | { allow: false; reason: 'maintenance' | FeatureState | 'not_configured' };
|
||||
type Evaluation = { allow: true; reason?: undefined } | { allow: false; reason: 'maintenance' | FeatureState | 'not_configured' };
|
||||
|
||||
@Injectable()
|
||||
export class FeatureAvailabilityGuard implements CanActivate {
|
||||
|
||||
37
apps/api/src/domain/policy/PolicyModule.test.ts
Normal file
37
apps/api/src/domain/policy/PolicyModule.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { PolicyController } from './PolicyController';
|
||||
import { PolicyModule } from './PolicyModule';
|
||||
import { PolicyService } from './PolicyService';
|
||||
import { FeatureAvailabilityGuard } from './FeatureAvailabilityGuard';
|
||||
|
||||
describe('PolicyModule', () => {
|
||||
let module: TestingModule;
|
||||
|
||||
beforeEach(async () => {
|
||||
module = await Test.createTestingModule({
|
||||
imports: [PolicyModule],
|
||||
}).compile();
|
||||
});
|
||||
|
||||
it('should compile the module', () => {
|
||||
expect(module).toBeDefined();
|
||||
});
|
||||
|
||||
it('should provide PolicyController', () => {
|
||||
const controller = module.get<PolicyController>(PolicyController);
|
||||
expect(controller).toBeDefined();
|
||||
expect(controller).toBeInstanceOf(PolicyController);
|
||||
});
|
||||
|
||||
it('should provide PolicyService', () => {
|
||||
const service = module.get<PolicyService>(PolicyService);
|
||||
expect(service).toBeDefined();
|
||||
expect(service).toBeInstanceOf(PolicyService);
|
||||
});
|
||||
|
||||
it('should provide FeatureAvailabilityGuard', () => {
|
||||
const guard = module.get<FeatureAvailabilityGuard>(FeatureAvailabilityGuard);
|
||||
expect(guard).toBeDefined();
|
||||
expect(guard).toBeInstanceOf(FeatureAvailabilityGuard);
|
||||
});
|
||||
});
|
||||
68
apps/api/src/domain/policy/RequireCapability.test.ts
Normal file
68
apps/api/src/domain/policy/RequireCapability.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { RequireCapability, FEATURE_AVAILABILITY_METADATA_KEY, FeatureAvailabilityMetadata } from './RequireCapability';
|
||||
import { ActionType } from './PolicyService';
|
||||
|
||||
// Mock SetMetadata
|
||||
vi.mock('@nestjs/common', () => ({
|
||||
SetMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('RequireCapability', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should call SetMetadata with correct key and metadata', () => {
|
||||
const capabilityKey = 'test-feature';
|
||||
const actionType: ActionType = 'view';
|
||||
|
||||
RequireCapability(capabilityKey, actionType);
|
||||
|
||||
expect(SetMetadata).toHaveBeenCalledWith(
|
||||
FEATURE_AVAILABILITY_METADATA_KEY,
|
||||
{
|
||||
capabilityKey,
|
||||
actionType,
|
||||
} satisfies FeatureAvailabilityMetadata
|
||||
);
|
||||
});
|
||||
|
||||
it('should work with mutate action type', () => {
|
||||
const capabilityKey = 'test-feature';
|
||||
const actionType: ActionType = 'mutate';
|
||||
|
||||
RequireCapability(capabilityKey, actionType);
|
||||
|
||||
expect(SetMetadata).toHaveBeenCalledWith(
|
||||
FEATURE_AVAILABILITY_METADATA_KEY,
|
||||
{
|
||||
capabilityKey,
|
||||
actionType,
|
||||
} satisfies FeatureAvailabilityMetadata
|
||||
);
|
||||
});
|
||||
|
||||
it('should work with different capability keys', () => {
|
||||
const capabilityKey = 'another-feature';
|
||||
const actionType: ActionType = 'view';
|
||||
|
||||
RequireCapability(capabilityKey, actionType);
|
||||
|
||||
expect(SetMetadata).toHaveBeenCalledWith(
|
||||
FEATURE_AVAILABILITY_METADATA_KEY,
|
||||
{
|
||||
capabilityKey,
|
||||
actionType,
|
||||
} satisfies FeatureAvailabilityMetadata
|
||||
);
|
||||
});
|
||||
|
||||
it('should return a decorator function', () => {
|
||||
const capabilityKey = 'test-feature';
|
||||
const actionType: ActionType = 'view';
|
||||
|
||||
const decorator = RequireCapability(capabilityKey, actionType);
|
||||
|
||||
expect(typeof decorator).toBe('function');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user