Add unit tests for channel utilities and configure testing environment
- Created a new test file `channels.test.ts` to cover utilities related to channel configurations and targets. - Implemented tests for normalizing and grouping selected channels by type, as well as building channel targets from account data and cron history. - Mocked necessary dependencies to isolate tests and ensure accurate results. - Updated `vite.config.ts` to set up the testing environment with jsdom and enable global variables for tests.
This commit is contained in:
391
electron/utils/channel-config.ts
Normal file
391
electron/utils/channel-config.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getUserDataDir } from './paths';
|
||||
|
||||
export const CHANNEL_STORE_FILE_NAME = 'channels.json';
|
||||
export const DEFAULT_CHANNEL_ACCOUNT_ID = 'default';
|
||||
|
||||
export interface StoredChannelAccountEntry {
|
||||
accountId: string;
|
||||
name?: string | null;
|
||||
channelUrl?: string | null;
|
||||
enabled?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface StoredChannelEntry {
|
||||
channelType: string;
|
||||
channelLabel?: string | null;
|
||||
defaultAccountId?: string | null;
|
||||
enabled?: boolean;
|
||||
accounts?: Record<string, StoredChannelAccountEntry>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface StoredChannelsDocument {
|
||||
channels?: Record<string, StoredChannelEntry>;
|
||||
}
|
||||
|
||||
export interface StoredChannelAccountRecord {
|
||||
channelType: string;
|
||||
channelLabel: string;
|
||||
defaultAccountId: string;
|
||||
channelEnabled: boolean;
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
accountEnabled: boolean;
|
||||
channelUrl?: string;
|
||||
config: Record<string, unknown>;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getStorePath(): string {
|
||||
return path.join(getUserDataDir(), CHANNEL_STORE_FILE_NAME);
|
||||
}
|
||||
|
||||
function formatChannelLabel(channelType: string, fallback?: string | null): string {
|
||||
const preferred = String(fallback ?? '').trim();
|
||||
if (preferred) return preferred;
|
||||
|
||||
const parts = String(channelType ?? '')
|
||||
.split(/[-_]/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (parts.length === 0) {
|
||||
return String(channelType ?? '').trim();
|
||||
}
|
||||
|
||||
return parts
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function normalizeChannelType(value: string): string {
|
||||
return String(value ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeAccountId(value: string | null | undefined): string {
|
||||
const trimmed = String(value ?? '').trim();
|
||||
return trimmed || DEFAULT_CHANNEL_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function ensureStoreDir(): void {
|
||||
fs.mkdirSync(path.dirname(getStorePath()), { recursive: true });
|
||||
}
|
||||
|
||||
function readStore(): StoredChannelsDocument {
|
||||
try {
|
||||
const filePath = getStorePath();
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { channels: {} };
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as StoredChannelsDocument;
|
||||
return {
|
||||
channels: parsed.channels && typeof parsed.channels === 'object' ? parsed.channels : {},
|
||||
};
|
||||
} catch {
|
||||
return { channels: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function writeStore(store: StoredChannelsDocument): void {
|
||||
ensureStoreDir();
|
||||
fs.writeFileSync(getStorePath(), JSON.stringify(store, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
function ensureChannelEntry(
|
||||
store: StoredChannelsDocument,
|
||||
channelType: string,
|
||||
): StoredChannelEntry {
|
||||
const normalizedChannelType = normalizeChannelType(channelType);
|
||||
if (!normalizedChannelType) {
|
||||
throw new Error('channelType is required');
|
||||
}
|
||||
|
||||
if (!store.channels) {
|
||||
store.channels = {};
|
||||
}
|
||||
|
||||
const existing = store.channels[normalizedChannelType];
|
||||
if (existing) {
|
||||
if (!existing.accounts || typeof existing.accounts !== 'object') {
|
||||
existing.accounts = {};
|
||||
}
|
||||
existing.channelType = normalizedChannelType;
|
||||
existing.channelLabel = formatChannelLabel(normalizedChannelType, existing.channelLabel);
|
||||
existing.defaultAccountId = normalizeAccountId(existing.defaultAccountId);
|
||||
existing.enabled = existing.enabled !== false;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const created: StoredChannelEntry = {
|
||||
channelType: normalizedChannelType,
|
||||
channelLabel: formatChannelLabel(normalizedChannelType),
|
||||
defaultAccountId: DEFAULT_CHANNEL_ACCOUNT_ID,
|
||||
enabled: true,
|
||||
accounts: {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
store.channels[normalizedChannelType] = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
function ensureAccountEntry(
|
||||
channel: StoredChannelEntry,
|
||||
accountId: string,
|
||||
): StoredChannelAccountEntry {
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
if (!channel.accounts || typeof channel.accounts !== 'object') {
|
||||
channel.accounts = {};
|
||||
}
|
||||
|
||||
const existing = channel.accounts[normalizedAccountId];
|
||||
if (existing) {
|
||||
existing.accountId = normalizedAccountId;
|
||||
existing.enabled = existing.enabled !== false;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const created: StoredChannelAccountEntry = {
|
||||
accountId: normalizedAccountId,
|
||||
name: normalizedAccountId,
|
||||
enabled: true,
|
||||
config: {},
|
||||
metadata: {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
channel.accounts[normalizedAccountId] = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
function coerceFormValues(config: Record<string, unknown> | undefined): Record<string, string> | undefined {
|
||||
if (!config || typeof config !== 'object') return undefined;
|
||||
|
||||
const values: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (value == null) continue;
|
||||
if (typeof value === 'string') {
|
||||
values[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
values[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(values).length > 0 ? values : undefined;
|
||||
}
|
||||
|
||||
export function isCanonicalChannelAccountId(value: string): boolean {
|
||||
return /^[a-z0-9](?:[a-z0-9_-]{0,63})$/.test(String(value ?? '').trim());
|
||||
}
|
||||
|
||||
export function listStoredChannelTypes(): string[] {
|
||||
return Object.keys(readStore().channels ?? {}).sort((left, right) => left.localeCompare(right, 'zh-CN'));
|
||||
}
|
||||
|
||||
export function hasStoredChannelAccount(channelType: string, accountId?: string | null): boolean {
|
||||
const normalizedChannelType = normalizeChannelType(channelType);
|
||||
if (!normalizedChannelType) return false;
|
||||
|
||||
const channel = readStore().channels?.[normalizedChannelType];
|
||||
if (!channel) return false;
|
||||
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
return Boolean(channel.accounts?.[normalizedAccountId]);
|
||||
}
|
||||
|
||||
export function listStoredChannelAccountRecords(): StoredChannelAccountRecord[] {
|
||||
const store = readStore();
|
||||
const records: StoredChannelAccountRecord[] = [];
|
||||
|
||||
for (const [rawChannelType, rawChannel] of Object.entries(store.channels ?? {})) {
|
||||
const channelType = normalizeChannelType(rawChannelType);
|
||||
if (!channelType || !rawChannel || typeof rawChannel !== 'object') continue;
|
||||
|
||||
const channelLabel = formatChannelLabel(channelType, rawChannel.channelLabel);
|
||||
const channelEnabled = rawChannel.enabled !== false;
|
||||
const accounts = rawChannel.accounts && typeof rawChannel.accounts === 'object'
|
||||
? rawChannel.accounts
|
||||
: {};
|
||||
const sortedAccountIds = Object.keys(accounts).sort((left, right) => {
|
||||
if (left === DEFAULT_CHANNEL_ACCOUNT_ID) return -1;
|
||||
if (right === DEFAULT_CHANNEL_ACCOUNT_ID) return 1;
|
||||
return left.localeCompare(right, 'zh-CN');
|
||||
});
|
||||
|
||||
const defaultAccountId = normalizeAccountId(
|
||||
rawChannel.defaultAccountId && sortedAccountIds.includes(normalizeAccountId(rawChannel.defaultAccountId))
|
||||
? rawChannel.defaultAccountId
|
||||
: sortedAccountIds[0],
|
||||
);
|
||||
|
||||
for (const accountId of sortedAccountIds) {
|
||||
const account = accounts[accountId];
|
||||
if (!account || typeof account !== 'object') continue;
|
||||
|
||||
records.push({
|
||||
channelType,
|
||||
channelLabel,
|
||||
defaultAccountId,
|
||||
channelEnabled,
|
||||
accountId,
|
||||
accountName: String(account.name ?? accountId).trim() || accountId,
|
||||
accountEnabled: account.enabled !== false,
|
||||
channelUrl: typeof account.channelUrl === 'string' ? account.channelUrl : undefined,
|
||||
config: account.config && typeof account.config === 'object' ? account.config : {},
|
||||
metadata: account.metadata && typeof account.metadata === 'object' ? account.metadata : {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return records.sort((left, right) => {
|
||||
if (left.channelLabel !== right.channelLabel) {
|
||||
return left.channelLabel.localeCompare(right.channelLabel, 'zh-CN');
|
||||
}
|
||||
return left.accountName.localeCompare(right.accountName, 'zh-CN');
|
||||
});
|
||||
}
|
||||
|
||||
export function getChannelFormValues(
|
||||
channelType: string,
|
||||
accountId?: string | null,
|
||||
): Record<string, string> | undefined {
|
||||
const normalizedChannelType = normalizeChannelType(channelType);
|
||||
if (!normalizedChannelType) return undefined;
|
||||
|
||||
const channel = readStore().channels?.[normalizedChannelType];
|
||||
if (!channel) return undefined;
|
||||
|
||||
const account = channel.accounts?.[normalizeAccountId(accountId)];
|
||||
if (!account) return undefined;
|
||||
|
||||
return coerceFormValues(account.config);
|
||||
}
|
||||
|
||||
export function saveChannelConfig(input: {
|
||||
channelType: string;
|
||||
accountId?: string | null;
|
||||
channelLabel?: string | null;
|
||||
accountName?: string | null;
|
||||
channelUrl?: string | null;
|
||||
enabled?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): StoredChannelEntry {
|
||||
const normalizedChannelType = normalizeChannelType(input.channelType);
|
||||
if (!normalizedChannelType) {
|
||||
throw new Error('channelType is required');
|
||||
}
|
||||
|
||||
const normalizedAccountId = normalizeAccountId(input.accountId);
|
||||
const store = readStore();
|
||||
const channel = ensureChannelEntry(store, normalizedChannelType);
|
||||
const account = ensureAccountEntry(channel, normalizedAccountId);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
channel.channelLabel = formatChannelLabel(normalizedChannelType, input.channelLabel ?? channel.channelLabel);
|
||||
channel.enabled = input.enabled ?? channel.enabled ?? true;
|
||||
channel.defaultAccountId = normalizeAccountId(channel.defaultAccountId || normalizedAccountId);
|
||||
channel.updatedAt = now;
|
||||
|
||||
account.name = String(input.accountName ?? account.name ?? normalizedAccountId).trim() || normalizedAccountId;
|
||||
account.channelUrl = typeof input.channelUrl === 'string'
|
||||
? input.channelUrl.trim() || undefined
|
||||
: account.channelUrl ?? undefined;
|
||||
account.enabled = input.enabled ?? account.enabled ?? true;
|
||||
account.config = input.config && typeof input.config === 'object' ? input.config : {};
|
||||
account.metadata = input.metadata && typeof input.metadata === 'object' ? input.metadata : (account.metadata ?? {});
|
||||
account.updatedAt = now;
|
||||
|
||||
if (!channel.accounts || Object.keys(channel.accounts).length === 1) {
|
||||
channel.defaultAccountId = normalizedAccountId;
|
||||
}
|
||||
|
||||
writeStore(store);
|
||||
return channel;
|
||||
}
|
||||
|
||||
export function setChannelDefaultAccount(channelType: string, accountId: string): StoredChannelEntry {
|
||||
const normalizedChannelType = normalizeChannelType(channelType);
|
||||
if (!normalizedChannelType) {
|
||||
throw new Error('channelType is required');
|
||||
}
|
||||
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const store = readStore();
|
||||
const channel = ensureChannelEntry(store, normalizedChannelType);
|
||||
if (!channel.accounts?.[normalizedAccountId]) {
|
||||
throw new Error(`Channel account "${normalizedChannelType}:${normalizedAccountId}" not found`);
|
||||
}
|
||||
|
||||
channel.defaultAccountId = normalizedAccountId;
|
||||
channel.updatedAt = new Date().toISOString();
|
||||
writeStore(store);
|
||||
return channel;
|
||||
}
|
||||
|
||||
export function setChannelEnabled(channelType: string, enabled: boolean): StoredChannelEntry {
|
||||
const normalizedChannelType = normalizeChannelType(channelType);
|
||||
if (!normalizedChannelType) {
|
||||
throw new Error('channelType is required');
|
||||
}
|
||||
|
||||
const store = readStore();
|
||||
const channel = ensureChannelEntry(store, normalizedChannelType);
|
||||
channel.enabled = Boolean(enabled);
|
||||
channel.updatedAt = new Date().toISOString();
|
||||
writeStore(store);
|
||||
return channel;
|
||||
}
|
||||
|
||||
export function deleteChannelConfig(channelType: string, accountId?: string | null): void {
|
||||
const normalizedChannelType = normalizeChannelType(channelType);
|
||||
if (!normalizedChannelType) {
|
||||
throw new Error('channelType is required');
|
||||
}
|
||||
|
||||
const store = readStore();
|
||||
const channels = store.channels ?? {};
|
||||
const channel = channels[normalizedChannelType];
|
||||
if (!channel) return;
|
||||
|
||||
const normalizedAccountId = accountId == null ? '' : normalizeAccountId(accountId);
|
||||
if (!normalizedAccountId) {
|
||||
delete channels[normalizedChannelType];
|
||||
writeStore(store);
|
||||
return;
|
||||
}
|
||||
|
||||
if (channel.accounts?.[normalizedAccountId]) {
|
||||
delete channel.accounts[normalizedAccountId];
|
||||
}
|
||||
|
||||
const remainingAccountIds = Object.keys(channel.accounts ?? {});
|
||||
if (remainingAccountIds.length === 0) {
|
||||
delete channels[normalizedChannelType];
|
||||
writeStore(store);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!remainingAccountIds.includes(normalizeAccountId(channel.defaultAccountId))) {
|
||||
channel.defaultAccountId = remainingAccountIds.sort((left, right) => {
|
||||
if (left === DEFAULT_CHANNEL_ACCOUNT_ID) return -1;
|
||||
if (right === DEFAULT_CHANNEL_ACCOUNT_ID) return 1;
|
||||
return left.localeCompare(right, 'zh-CN');
|
||||
})[0];
|
||||
}
|
||||
|
||||
channel.updatedAt = new Date().toISOString();
|
||||
writeStore(store);
|
||||
}
|
||||
Reference in New Issue
Block a user