Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"pg": "^8.16.3",
"prisma": "^7.0.0",
"sharp": "^0.34.0",
"tslog": "^4.9.3"
"tslog": "^4.9.3",
"yaml": "^2.8.3"
},
"peerDependencies": {
"zod": "^4.0.0"
Expand Down
25 changes: 25 additions & 0 deletions pkg/config/adaptor/dummy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';

import { Config } from '../model/config.js';
import { DummyConfigStore } from './dummy.js';

const testConfig = Config.new({
instanceName: 'Pulsate Demo Server',
instanceFqdn: 'demo.pulsate.dev',
openRegistration: true,
maintainerAccount: '@pulsateprj@demo.pulsate.dev',
maintainerEmail: 'contact@pulsate.dev',
});

describe('DummyConfigStore', () => {
it('should return the config', () => {
const store = new DummyConfigStore(testConfig);
const config = store.fetch();

expect(config.getInstanceName()).toBe('Pulsate Demo Server');
expect(config.getInstanceFqdn()).toBe('demo.pulsate.dev');
expect(config.isOpenRegistration()).toBe(true);
expect(config.getMaintainerAccount()).toBe('@pulsateprj@demo.pulsate.dev');
expect(config.getMaintainerEmail()).toBe('contact@pulsate.dev');
});
});
18 changes: 18 additions & 0 deletions pkg/config/adaptor/dummy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Ether } from '@mikuroxina/mini-fn';
import { type ConfigStore, configStoreSymbol } from '../mod.js';
import type { Config } from '../model/config.js';

export class DummyConfigStore implements ConfigStore {
private readonly config: Config;

constructor(config: Config) {
this.config = config;
}

fetch(): Config {
return this.config;
}
}

export const dummyConfigStore = (config: Config) =>
Ether.newEther(configStoreSymbol, () => new DummyConfigStore(config));
78 changes: 78 additions & 0 deletions pkg/config/adaptor/local.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { describe, expect, it } from 'vitest';

import { ConfigInvalidError } from '../model/errors.js';
import { LocalConfigStore } from './local.js';

describe('LocalConfigStore', () => {
const createTempConfig = (content: string): string => {
const dir = mkdtempSync(join(tmpdir(), 'pulsate-config-test-'));
const filePath = join(dir, 'config.yaml');
writeFileSync(filePath, content);
return filePath;
};

it('should load config from a valid YAML file', () => {
const filePath = createTempConfig(`
instance_name: "Pulsate Demo Server"
instance_fqdn: "demo.pulsate.dev"
open_registration: true
maintainer_account: "@pulsateprj@demo.pulsate.dev"
maintainer_email: "contact@pulsate.dev"
`);

const store = new LocalConfigStore(filePath);
const config = store.fetch();

expect(config.getInstanceName()).toBe('Pulsate Demo Server');
expect(config.getInstanceFqdn()).toBe('demo.pulsate.dev');
expect(config.isOpenRegistration()).toBe(true);
expect(config.getMaintainerAccount()).toBe('@pulsateprj@demo.pulsate.dev');
expect(config.getMaintainerEmail()).toBe('contact@pulsate.dev');

rmSync(join(filePath, '..'), { recursive: true });
});

it('should throw when file does not exist', () => {
expect(() => new LocalConfigStore('/nonexistent/path.yaml')).toThrow();
});

it('should throw ConfigInvalidError when required field is empty', () => {
const filePath = createTempConfig(`
instance_name: ""
instance_fqdn: "demo.pulsate.dev"
open_registration: true
maintainer_account: "@pulsateprj@demo.pulsate.dev"
maintainer_email: "contact@pulsate.dev"
`);

expect(() => new LocalConfigStore(filePath)).toThrow(ConfigInvalidError);

rmSync(join(filePath, '..'), { recursive: true });
});

it('should throw ConfigInvalidError when maintainer_account has extra @', () => {
const filePath = createTempConfig(`
instance_name: "Pulsate Demo Server"
instance_fqdn: "demo.pulsate.dev"
open_registration: true
maintainer_account: "@a@@b"
maintainer_email: "contact@pulsate.dev"
`);

expect(() => new LocalConfigStore(filePath)).toThrow(ConfigInvalidError);

rmSync(join(filePath, '..'), { recursive: true });
});

it('should throw when YAML is malformed', () => {
const filePath = createTempConfig('{{{invalid yaml');

expect(() => new LocalConfigStore(filePath)).toThrow();

rmSync(join(filePath, '..'), { recursive: true });
});
});
31 changes: 31 additions & 0 deletions pkg/config/adaptor/local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { readFileSync } from 'node:fs';

import { Ether } from '@mikuroxina/mini-fn';
import { parse } from 'yaml';

import { type ConfigStore, configStoreSymbol } from '../mod.js';
import { type AccountName, Config } from '../model/config.js';

export class LocalConfigStore implements ConfigStore {
private readonly config: Config;

constructor(filePath: string) {
const content = readFileSync(filePath, 'utf-8');
const data = parse(content);

this.config = Config.new({
instanceName: data.instance_name,
instanceFqdn: data.instance_fqdn,
openRegistration: data.open_registration,
maintainerAccount: data.maintainer_account as AccountName,
maintainerEmail: data.maintainer_email,
});
}

fetch(): Config {
return this.config;
}
}

export const localConfigStore = (filePath: string) =>
Ether.newEther(configStoreSymbol, () => new LocalConfigStore(filePath));
8 changes: 8 additions & 0 deletions pkg/config/mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Ether } from '@mikuroxina/mini-fn';

import type { Config } from './model/config.js';

export interface ConfigStore {
fetch(): Config;
}
export const configStoreSymbol = Ether.newEtherSymbol<ConfigStore>();
60 changes: 60 additions & 0 deletions pkg/config/model/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';

import { Config } from './config.js';
import { ConfigInvalidError } from './errors.js';

const validArgs = {
instanceName: 'Pulsate Demo Server',
instanceFqdn: 'demo.pulsate.dev',
openRegistration: true,
maintainerAccount: '@pulsateprj@demo.pulsate.dev' as const,
maintainerEmail: 'contact@pulsate.dev',
};

describe('Config', () => {
it('should create config with valid args', () => {
const config = Config.new(validArgs);

expect(config.getInstanceName()).toBe('Pulsate Demo Server');
expect(config.getInstanceFqdn()).toBe('demo.pulsate.dev');
expect(config.isOpenRegistration()).toBe(true);
expect(config.getMaintainerAccount()).toBe('@pulsateprj@demo.pulsate.dev');
expect(config.getMaintainerEmail()).toBe('contact@pulsate.dev');
});

it('should throw when instanceName is empty', () => {
expect(() => Config.new({ ...validArgs, instanceName: '' })).toThrow(
ConfigInvalidError,
);
});

it('should throw when instanceFqdn is empty', () => {
expect(() => Config.new({ ...validArgs, instanceFqdn: '' })).toThrow(
ConfigInvalidError,
);
});

it('should throw when maintainerAccount is invalid format', () => {
expect(() =>
Config.new({ ...validArgs, maintainerAccount: '@@' as const }),
).toThrow(ConfigInvalidError);
});

it('should throw when maintainerAccount has extra @ separators', () => {
expect(() =>
Config.new({ ...validArgs, maintainerAccount: '@a@@b' as const }),
).toThrow(ConfigInvalidError);
expect(() =>
Config.new({ ...validArgs, maintainerAccount: '@a@b@c' as const }),
).toThrow(ConfigInvalidError);
expect(() =>
Config.new({ ...validArgs, maintainerAccount: '@@@@@' as const }),
).toThrow(ConfigInvalidError);
});

it('should throw when maintainerEmail is empty', () => {
expect(() => Config.new({ ...validArgs, maintainerEmail: '' })).toThrow(
ConfigInvalidError,
);
});
});
75 changes: 75 additions & 0 deletions pkg/config/model/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { ConfigInvalidError } from './errors.js';

export type AccountName = `@${string}@${string}`;

export interface CreateConfigArgs {
instanceName: string;
instanceFqdn: string;
openRegistration: boolean;
maintainerAccount: AccountName;
maintainerEmail: string;
}

export class Config {
private constructor(arg: CreateConfigArgs) {
this.instanceName = arg.instanceName;
this.instanceFqdn = arg.instanceFqdn;
this.openRegistration = arg.openRegistration;
this.maintainerAccount = arg.maintainerAccount;
this.maintainerEmail = arg.maintainerEmail;
}

private readonly instanceName: string;
getInstanceName(): string {
return this.instanceName;
}

private readonly instanceFqdn: string;
getInstanceFqdn(): string {
return this.instanceFqdn;
}

private readonly openRegistration: boolean;
isOpenRegistration(): boolean {
return this.openRegistration;
}

private readonly maintainerAccount: AccountName;
getMaintainerAccount(): AccountName {
return this.maintainerAccount;
}

private readonly maintainerEmail: string;
getMaintainerEmail(): string {
return this.maintainerEmail;
}

public static new(arg: CreateConfigArgs): Config {
if (arg.instanceName === '') {
throw new ConfigInvalidError('instanceName is required', {
cause: arg.instanceName,
});
}
if (arg.instanceFqdn === '') {
throw new ConfigInvalidError('instanceFqdn is required', {
cause: arg.instanceFqdn,
});
}

// validate maintainerAccount: must be exactly `@user@host` with non-empty parts and no extra `@`
if (!/^@[^@]+@[^@]+$/.test(arg.maintainerAccount)) {
throw new ConfigInvalidError(
'maintainerAccount must be in @user@host format',
{ cause: arg.maintainerAccount },
);
}

if (arg.maintainerEmail === '') {
throw new ConfigInvalidError('maintainerEmail is required', {
cause: arg.maintainerEmail,
});
}

return new Config(arg);
}
}
7 changes: 7 additions & 0 deletions pkg/config/model/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class ConfigInvalidError extends Error {
constructor(message: string, options: { cause: unknown }) {
super(message);
this.name = 'ConfigInvalidError';
this.cause = options.cause;
}
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading