DC-140: network provider abstraction (src/network/) + tailscale provider + registry + tests
This commit is contained in:
7 files changed
+1308
No files matched your search
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Tests for src/network/registry.js (DC-140).
|
||||
*
|
||||
* Hermetic: no network, no filesystem. Providers are stub factories whose
|
||||
* instances are plain objects implementing the documented contract surface
|
||||
* (see src/network/provider.js).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { registry } = require('../src/network');
|
||||
const { ProviderNotActiveError, ProviderError } = require('../src/network/errors');
|
||||
const { CONTRACT_METHODS } = require('../src/network/provider');
|
||||
|
||||
/** Build a minimal contract-satisfying stub provider. */
|
||||
function stubProvider(id, caps = {}) {
|
||||
const p = {};
|
||||
for (const m of CONTRACT_METHODS) {
|
||||
p[m] = m === 'id' ? () => id : m === 'capabilities' ? () => ({
|
||||
invites: false,
|
||||
splitDnsApi: false,
|
||||
perPeerFirewall: false,
|
||||
selfHosted: false,
|
||||
...caps,
|
||||
}) : async () => ({});
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
registry.reset();
|
||||
});
|
||||
|
||||
describe('network registry: inactive default', () => {
|
||||
test('defaults to inactive — getActiveProvider() throws ProviderNotActiveError', () => {
|
||||
expect(() => registry.getActiveProvider()).toThrow(ProviderNotActiveError);
|
||||
});
|
||||
|
||||
test('getCapabilities() throws ProviderNotActiveError when unset', () => {
|
||||
expect(() => registry.getCapabilities()).toThrow(ProviderNotActiveError);
|
||||
});
|
||||
|
||||
test('active id is null before activation', () => {
|
||||
expect(registry.getActiveProviderId()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('network registry: registration', () => {
|
||||
test('registerProvider stores a factory under its id', () => {
|
||||
registry.registerProvider('stub-a', () => stubProvider('stub-a'));
|
||||
expect(registry.listProviderIds()).toContain('stub-a');
|
||||
});
|
||||
|
||||
test('registerProvider rejects a non-function factory', () => {
|
||||
expect(() => registry.registerProvider('bad', 'not-a-function')).toThrow(TypeError);
|
||||
});
|
||||
|
||||
test('registerProvider rejects an empty id', () => {
|
||||
expect(() => registry.registerProvider('', () => stubProvider('x'))).toThrow(TypeError);
|
||||
});
|
||||
|
||||
test('registerProvider re-registers over an existing id', () => {
|
||||
registry.registerProvider('stub-a', () => stubProvider('stub-a'));
|
||||
registry.registerProvider('stub-a', () => stubProvider('stub-a'));
|
||||
expect(registry.listProviderIds().filter((i) => i === 'stub-a')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network registry: activation + resolution', () => {
|
||||
test('setActiveProvider(id) resolves the instance via the factory', () => {
|
||||
const sentinel = stubProvider('stub-a');
|
||||
registry.registerProvider('stub-a', () => sentinel);
|
||||
const active = registry.setActiveProvider('stub-a');
|
||||
expect(active).toBe(sentinel);
|
||||
expect(registry.getActiveProvider()).toBe(sentinel);
|
||||
expect(registry.getActiveProviderId()).toBe('stub-a');
|
||||
});
|
||||
|
||||
test('setActiveProvider throws for an unregistered id', () => {
|
||||
expect(() => registry.setActiveProvider('does-not-exist')).toThrow(/Unknown network provider/);
|
||||
});
|
||||
|
||||
test('setActiveProvider rebuilds the instance when the factory is replaced', () => {
|
||||
const first = stubProvider('stub-a');
|
||||
const second = stubProvider('stub-a');
|
||||
registry.registerProvider('stub-a', () => first);
|
||||
registry.setActiveProvider('stub-a');
|
||||
registry.registerProvider('stub-a', () => second);
|
||||
expect(registry.getActiveProvider()).toBe(second);
|
||||
});
|
||||
|
||||
test('setActiveProvider rejects an instance missing contract methods', () => {
|
||||
registry.registerProvider('incomplete', () => ({ id: () => 'incomplete' }));
|
||||
expect(() => registry.setActiveProvider('incomplete')).toThrow(/contract/i);
|
||||
});
|
||||
|
||||
test('setActiveProvider rejects an instance whose id() mismatches', () => {
|
||||
registry.registerProvider('stub-a', () => stubProvider('stub-b'));
|
||||
expect(() => registry.setActiveProvider('stub-a')).toThrow(/different id/i);
|
||||
});
|
||||
|
||||
test('reset() returns the registry to inactive', () => {
|
||||
registry.registerProvider('stub-a', () => stubProvider('stub-a'));
|
||||
registry.setActiveProvider('stub-a');
|
||||
registry.reset();
|
||||
expect(() => registry.getActiveProvider()).toThrow(ProviderNotActiveError);
|
||||
expect(registry.getActiveProviderId()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('network registry: capability flags', () => {
|
||||
test('getCapabilities() returns the active provider capabilities', () => {
|
||||
registry.registerProvider(
|
||||
'stub-ts',
|
||||
() => stubProvider('stub-ts', { invites: true, splitDnsApi: true })
|
||||
);
|
||||
registry.setActiveProvider('stub-ts');
|
||||
expect(registry.getCapabilities()).toEqual({
|
||||
invites: true,
|
||||
splitDnsApi: true,
|
||||
perPeerFirewall: false,
|
||||
selfHosted: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('network errors', () => {
|
||||
test('ProviderNotActiveError is a ProviderError with code provider_not_active', () => {
|
||||
const e = new ProviderNotActiveError();
|
||||
expect(e).toBeInstanceOf(ProviderError);
|
||||
expect(e.code).toBe('provider_not_active');
|
||||
expect(e.retryable).toBe(false);
|
||||
});
|
||||
|
||||
test('ProviderError derives retryable from code when not given', () => {
|
||||
expect(new ProviderError('x', { code: 'rate_limited' }).retryable).toBe(true);
|
||||
expect(new ProviderError('x', { code: 'server_error' }).retryable).toBe(true);
|
||||
expect(new ProviderError('x', { code: 'network_error' }).retryable).toBe(true);
|
||||
expect(new ProviderError('x', { code: 'unauthorized' }).retryable).toBe(false);
|
||||
expect(new ProviderError('x', { code: 'bad_input' }).retryable).toBe(false);
|
||||
});
|
||||
|
||||
test('ProviderError honors an explicit retryable override', () => {
|
||||
expect(new ProviderError('x', { code: 'rate_limited', retryable: false }).retryable).toBe(false);
|
||||
expect(new ProviderError('x', { code: 'unknown', retryable: true }).retryable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network index: built-in tailscale registration', () => {
|
||||
test('tailscale is registered and activatable via the package entry point', () => {
|
||||
// The package entry registers the real tailscale factory; activation
|
||||
// builds a real provider instance (lazy deps, no network at build time).
|
||||
expect(registry.listProviderIds()).toContain('tailscale');
|
||||
const provider = registry.setActiveProvider('tailscale');
|
||||
expect(provider.id()).toBe('tailscale');
|
||||
expect(provider.capabilities()).toEqual({
|
||||
invites: true,
|
||||
splitDnsApi: true,
|
||||
perPeerFirewall: false,
|
||||
selfHosted: false,
|
||||
});
|
||||
// And it deactivates cleanly again.
|
||||
registry.reset();
|
||||
expect(() => registry.getActiveProvider()).toThrow(ProviderNotActiveError);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user