kt-template-online-api/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts

869 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

jest.mock('@/common', () => {
const actualCommon = jest.requireActual('@/common');
return {
...actualCommon,
ToolsService: actualCommon.ToolsService,
ensureSnowflakeId: jest.fn(),
setDictDecodeCache: jest.fn(),
/**
* 执行 NapCat回调。
* @param message - message 输入;驱动 `Error()` 的 NapCat步骤。
*/
throwVbenError: (message: string) => {
throw new Error(message);
},
};
});
import { ConfigService } from '@nestjs/config';
import { ToolsService } from '@/common';
import { QqbotNapcatContainerService } from '@/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service';
describe('QqbotNapcatContainerService', () => {
it('removes previous account containers when binding a new primary container', async () => {
const bindingRepository = {
create: jest.fn((input) => input),
createQueryBuilder: jest.fn(() => ({
andWhere: jest.fn().mockReturnThis(),
getCount: jest.fn().mockResolvedValue(0),
where: jest.fn().mockReturnThis(),
})),
find: jest.fn().mockResolvedValue([
{
accountId: 'account-1',
containerId: 'container-old',
id: 'binding-old',
isDeleted: false,
},
]),
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn(),
update: jest.fn(),
};
const containerRepository = {
findOne: jest.fn().mockResolvedValue({
id: 'container-old',
isDeleted: false,
name: 'napcat-old',
}),
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{ get: jest.fn().mockReturnValue('') } as unknown as ConfigService,
containerRepository as any,
bindingRepository as any,
new ToolsService(),
);
jest.spyOn(service as any, 'getManagedMode').mockReturnValue('');
await service.bindAccount('account-1', 'container-new');
expect(bindingRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-new',
isPrimary: true,
}),
);
expect(containerRepository.update).toHaveBeenCalledWith(
{ id: 'container-old' },
expect.objectContaining({
isDeleted: true,
status: 'stopped',
}),
);
expect(bindingRepository.update).toHaveBeenCalledWith(
{ id: 'binding-old' },
expect.objectContaining({
bindStatus: 'disabled',
isDeleted: true,
isPrimary: false,
}),
);
});
it('backfills container ownership when binding an account to a legacy container', async () => {
const bindingRepository = {
create: jest.fn((input) => input),
createQueryBuilder: jest.fn(() => ({
andWhere: jest.fn().mockReturnThis(),
getCount: jest.fn().mockResolvedValue(0),
where: jest.fn().mockReturnThis(),
})),
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue({
accountId: 'account-1',
containerId: 'container-old',
id: 'binding-1',
isDeleted: false,
}),
save: jest.fn(),
update: jest.fn(),
};
const containerRepository = {
findOne: jest.fn(),
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{ get: jest.fn().mockReturnValue('') } as unknown as ConfigService,
containerRepository as any,
bindingRepository as any,
new ToolsService(),
);
await service.bindAccount('account-1', 'container-1');
expect(containerRepository.update).toHaveBeenCalledWith(
{ id: 'container-1', isDeleted: false },
{ accountId: 'account-1' },
);
expect(bindingRepository.update).toHaveBeenCalledWith(
{ id: 'binding-1' },
expect.objectContaining({
bindStatus: 'bound',
containerId: 'container-1',
isPrimary: true,
}),
);
});
it('uses the latest NapCat account status line as runtime offline truth', () => {
const service = new QqbotNapcatContainerService(
{ get: jest.fn() } as any,
{} as any,
{} as any,
new ToolsService(),
) as any;
expect(
service.extractLoginState(`
06-11 08:39:19 [info] Mirror | 账号状态变更为离线
06-11 08:41:19 [info] Mirror | 账号状态变更为在线
`).offlineReason,
).toBeNull();
});
it('treats isOnline false as an offline runtime state', () => {
const service = new QqbotNapcatContainerService(
{ get: jest.fn() } as any,
{} as any,
{} as any,
new ToolsService(),
) as any;
const result = service.extractLoginState(`
06-11 08:39:19 [info] Mirror | {"isOnline": false}
`);
expect(result).toEqual({
offlineReason: 'NapCat 账号状态变更为离线',
state: 'offline',
});
});
it('clears stale offline error when the latest NapCat account status is online', async () => {
const containerRepository = {
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string) => {
const values: Record<string, string> = {
QQBOT_NAPCAT_CONTAINER_MODE: 'ssh',
QQBOT_NAPCAT_SSH_TARGET: 'nas',
};
return values[key] || '';
}),
} as any,
containerRepository as any,
{} as any,
new ToolsService(),
) as any;
service.runProcess = jest.fn().mockResolvedValue({
stderr: '',
stdout: `
06-11 08:39:19 [info] Mirror | 账号状态变更为离线
06-11 08:41:19 [info] Mirror | 账号状态变更为在线
`,
});
const reason = await service.detectRuntimeOffline({
id: 'container-1',
name: 'kt-qqbot-napcat-1914728559',
});
expect(reason).toBeNull();
expect(containerRepository.update).toHaveBeenCalledWith(
{ id: 'container-1' },
expect.objectContaining({
lastCheckedAt: expect.any(Date),
lastError: null,
}),
);
});
it('extracts the latest NapCat offline reason from container logs', () => {
const service = new QqbotNapcatContainerService(
{ get: jest.fn() } as any,
{} as any,
{} as any,
new ToolsService(),
) as any;
expect(
service.extractLoginState(`
06-11 08:39:19 [info] Mirror | [KickedOffLine] [下线通知] 您的账号已在另一台终端登录
06-11 08:40:49 [info] Mirror | 账号状态变更为离线
`).offlineReason,
).toBe('账号状态变更为离线');
});
it('uses a short timeout for runtime offline log detection', async () => {
const containerRepository = {
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string) => {
const values: Record<string, string> = {
QQBOT_NAPCAT_CONTAINER_MODE: 'ssh',
QQBOT_NAPCAT_RUNTIME_CHECK_TIMEOUT_MS: '5000',
QQBOT_NAPCAT_SSH_TARGET: 'nas',
};
return values[key] || '';
}),
} as any,
containerRepository as any,
{} as any,
new ToolsService(),
) as any;
service.runProcess = jest.fn().mockResolvedValue({
stderr: '',
stdout: '06-11 08:39:19 [info] Mirror | 账号状态变更为离线',
});
const reason = await service.detectRuntimeOffline({
id: 'container-1',
name: 'kt-qqbot-napcat-1914728559',
});
expect(reason).toBe('账号状态变更为离线');
expect(service.runProcess).toHaveBeenCalledWith(
'ssh',
expect.any(Array),
expect.any(String),
undefined,
5000,
);
});
it('truncates runtime offline reason before writing container lastError', async () => {
const containerRepository = {
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string) => {
const values: Record<string, string> = {
QQBOT_NAPCAT_CONTAINER_MODE: 'ssh',
QQBOT_NAPCAT_SSH_TARGET: 'nas',
};
return values[key] || '';
}),
} as any,
containerRepository as any,
{} as any,
new ToolsService(),
) as any;
service.runProcess = jest.fn().mockResolvedValue({
stderr: '',
stdout: `06-11 08:39:19 [info] Mirror | [KickedOffLine] [下线通知] ${'错误'.repeat(300)}`,
});
await service.detectRuntimeOffline({
id: 'container-1',
name: 'kt-qqbot-napcat-1914728559',
});
expect(containerRepository.update).toHaveBeenCalledWith(
{ id: 'container-1' },
expect.objectContaining({
lastError: `${'错误'.repeat(248)}错...`,
}),
);
});
it('truncates runtime check errors before writing container lastError', async () => {
const containerRepository = {
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string) => {
const values: Record<string, string> = {
QQBOT_NAPCAT_CONTAINER_MODE: 'ssh',
QQBOT_NAPCAT_SSH_TARGET: 'nas',
};
return values[key] || '';
}),
} as any,
containerRepository as any,
{} as any,
new ToolsService(),
) as any;
service.runProcess = jest
.fn()
.mockRejectedValue(new Error('错误'.repeat(300)));
await service.detectRuntimeOffline({
id: 'container-1',
name: 'kt-qqbot-napcat-1914728559',
});
expect(containerRepository.update).toHaveBeenCalledWith(
{ id: 'container-1' },
expect.objectContaining({
lastError: `${'错误'.repeat(248)}错...`,
}),
);
});
it('injects ACCOUNT and NAPCAT_QUICK_PASSWORD env only when provided', () => {
const service = new QqbotNapcatContainerService(
{ get: jest.fn().mockReturnValue('') } as any,
{} as any,
{} as any,
new ToolsService(),
) as any;
const baseInput = {
dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-test',
image: 'mlikiowa/napcat-docker:latest',
name: 'kt-qqbot-napcat-test',
port: 6100,
reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse',
token: 'token-test',
};
const withAccount = service.buildRemoteCreateScript({
...baseInput,
account: '2354598417',
loginPassword: 'qq-password',
});
expect(withAccount).toContain("ACCOUNT='2354598417'");
expect(withAccount).toContain('-e ACCOUNT="$ACCOUNT"');
expect(withAccount).toContain("NAPCAT_QUICK_PASSWORD='qq-password'");
expect(withAccount).toContain(
'-e NAPCAT_QUICK_PASSWORD="$NAPCAT_QUICK_PASSWORD"',
);
const withoutAccount = service.buildRemoteCreateScript(baseInput);
expect(withoutAccount).not.toContain('-e ACCOUNT="$ACCOUNT"');
expect(withoutAccount).not.toMatch(/^ACCOUNT=/m);
expect(withoutAccount).not.toContain('NAPCAT_QUICK_PASSWORD');
});
it('skips docker pull when recreating in place for quick login', () => {
const service = new QqbotNapcatContainerService(
{ get: jest.fn().mockReturnValue('') } as any,
{} as any,
{} as any,
new ToolsService(),
) as any;
const baseInput = {
dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-test',
image: 'mlikiowa/napcat-docker:latest',
name: 'kt-qqbot-napcat-test',
port: 6100,
reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse',
token: 'token-test',
};
expect(service.buildRemoteCreateScript(baseInput)).toContain('docker pull');
expect(
service.buildRemoteCreateScript({ ...baseInput, skipPull: true }),
).not.toContain('docker pull');
});
it('generates Chinese desktop runtime flags and account config files', () => {
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string, defaultValue?: string) => {
const values: Record<string, string> = {
QQBOT_NAPCAT_IMAGE: 'kt-napcat-desktop-cn@sha256:profiledigest',
QQBOT_NAPCAT_RUNTIME_GID: '1101',
QQBOT_NAPCAT_RUNTIME_UID: '1101',
QQBOT_NAPCAT_SHM_SIZE: '512m',
};
return values[key] || defaultValue || '';
}),
} as any,
{} as any,
{} as any,
new ToolsService(),
) as any;
const script = service.buildRemoteCreateScript({
account: '10001',
dataDir: '/vol1/docker/kt-qqbot/napcat-instances/linux-pc-a1b2',
image: 'kt-napcat-desktop-cn@sha256:profiledigest',
name: 'linux-pc-a1b2',
port: 6100,
reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse',
token: 'token-test',
});
expect(script).toContain(
'mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" "$DATA_DIR/cache" "$DATA_DIR/local-share"',
);
expect(script).toContain("NAPCAT_UID='1101'");
expect(script).toContain("NAPCAT_GID='1101'");
expect(script).toContain("NAPCAT_SHM_SIZE='512m'");
expect(script).toContain('--init \\');
expect(script).toContain('--shm-size "$NAPCAT_SHM_SIZE"');
expect(script).toContain('-e NAPCAT_UID="$NAPCAT_UID"');
expect(script).toContain('-e NAPCAT_GID="$NAPCAT_GID"');
expect(script).toContain('-e LANG=zh_CN.UTF-8');
expect(script).toContain('-e LC_ALL=zh_CN.UTF-8');
expect(script).toContain('-e LANGUAGE=zh_CN:zh');
expect(script).toContain('-e TZ=Asia/Shanghai');
expect(script).toContain('-e HOME=/app');
expect(script).toContain('-e XDG_CONFIG_HOME=/app/.config');
expect(script).toContain('-e XDG_CACHE_HOME=/app/.cache');
expect(script).toContain('-e XDG_DATA_HOME=/app/.local/share');
expect(script).toContain('-e XDG_RUNTIME_DIR=/tmp/runtime-napcat');
expect(script).toContain('-v "$DATA_DIR/cache:/app/.cache"');
expect(script).toContain('-v "$DATA_DIR/local-share:/app/.local/share"');
expect(script).toContain('-v "$DATA_DIR/logs:/app/napcat/logs"');
expect(script).toContain('cat > "$DATA_DIR/config/napcat_10001.json"');
expect(script).toContain('cat > "$DATA_DIR/config/onebot11_10001.json"');
});
it('skips quick-login recreate when the container already carries ACCOUNT', async () => {
const containerRepository = {
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue({
dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-x',
id: 'container-1',
image: 'mlikiowa/napcat-docker:latest',
name: 'kt-qqbot-napcat-x',
reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse',
webuiPort: 6100,
webuiToken: 'token-x',
}),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string) =>
key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '',
),
} as any,
containerRepository as any,
{} as any,
new ToolsService(),
) as any;
service.runProcess = jest
.fn()
.mockResolvedValue({ stderr: '', stdout: 'ACCOUNT=2354598417\nPATH=/x' });
const recreated = await service.ensureRuntimeQuickLogin(
{ id: 'container-1', name: 'kt-qqbot-napcat-x' },
'2354598417',
);
expect(recreated).toEqual({ changed: false, ok: true });
// 仅做了 inspect 检查没有触发任何重建rm/run
expect(service.runProcess).toHaveBeenCalledTimes(1);
expect(service.runProcess.mock.calls[0][1]).toEqual(
expect.arrayContaining(['sh -s']),
);
expect(service.runProcess.mock.calls[0][2]).toContain(
'docker inspect --format \'{{range .Config.Env}}{{println .}}{{end}}\' "$NAME"',
);
expect(containerRepository.update).not.toHaveBeenCalled();
});
it('does not recreate quick-login env when the bound source container is already online', async () => {
const bindingRepository = {
findOne: jest.fn().mockResolvedValue({
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-online',
isDeleted: false,
isPrimary: true,
}),
};
const containerRepository = {
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue({
baseUrl: 'http://127.0.0.1:6100/',
dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-x',
id: 'container-online',
image: 'mlikiowa/napcat-docker:latest',
lastError: null,
name: 'kt-qqbot-napcat-x',
reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse',
status: 'running',
webuiPort: 6100,
webuiToken: 'token-x',
}),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string) =>
key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '',
),
} as any,
containerRepository as any,
bindingRepository as any,
new ToolsService(),
) as any;
const ensureQuickLogin = jest.spyOn(service, 'ensureRuntimeQuickLogin');
jest.spyOn(service, 'inspectRuntimeStatus').mockResolvedValue({
containerOnline: true,
qqLoginStatus: 'online',
webuiOnline: true,
});
const runtime = await service.prepareAccountContainer({
id: 'account-1',
selfId: '2354598417',
});
expect(ensureQuickLogin).not.toHaveBeenCalled();
expect(runtime).toEqual(
expect.objectContaining({
hasExistingPrimaryBinding: true,
runtimeRebuildCount: 0,
sourceContainerOnline: true,
}),
);
});
it('starts a stopped primary container when login env already matches', async () => {
const bindingRepository = {
findOne: jest.fn().mockResolvedValue({
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-stopped',
isDeleted: false,
isPrimary: true,
}),
};
const containerRepository = {
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue({
baseUrl: 'http://127.0.0.1:6100/',
dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-x',
id: 'container-stopped',
image: 'mlikiowa/napcat-docker:latest',
lastError: null,
name: 'kt-qqbot-napcat-x',
reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse',
status: 'stopped',
webuiPort: 6100,
webuiToken: 'token-x',
}),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string) =>
key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '',
),
} as any,
containerRepository as any,
bindingRepository as any,
new ToolsService(),
) as any;
service.runProcess = jest
.fn()
.mockResolvedValueOnce({
stderr: '',
stdout: 'ACCOUNT=2354598417\nPATH=/x',
})
.mockResolvedValueOnce({ stderr: '', stdout: 'kt-qqbot-napcat-x\n' });
const runtime = await service.prepareAccountContainer({
id: 'account-1',
selfId: '2354598417',
});
expect(runtime).toEqual(
expect.objectContaining({
hasExistingPrimaryBinding: true,
runtimeRebuildCount: 0,
sourceContainerOnline: false,
}),
);
expect(service.runProcess).toHaveBeenCalledTimes(2);
expect(service.runProcess.mock.calls[1][1]).toEqual(
expect.arrayContaining(['docker', 'start', 'kt-qqbot-napcat-x']),
);
expect(containerRepository.update).toHaveBeenCalledWith(
{ id: 'container-stopped' },
expect.objectContaining({
lastError: null,
status: 'running',
}),
);
});
it('recreates login env when docker env inspection fails', async () => {
const containerRepository = {
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue({
dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-x',
id: 'container-1',
image: 'mlikiowa/napcat-docker:latest',
name: 'kt-qqbot-napcat-x',
reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse',
webuiPort: 6100,
webuiToken: 'token-x',
}),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string) =>
key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '',
),
} as any,
containerRepository as any,
{} as any,
new ToolsService(),
) as any;
service.runProcess = jest
.fn()
.mockRejectedValueOnce(new Error('inspect failed'))
.mockResolvedValueOnce({ stderr: '', stdout: '' })
.mockResolvedValueOnce({
stderr: '',
stdout: 'ACCOUNT=2354598417\nNAPCAT_QUICK_PASSWORD=qq-password\n',
});
const recreated = await service.ensureRuntimeLoginEnv(
{ id: 'container-1', name: 'kt-qqbot-napcat-x' },
{
loginPassword: 'qq-password',
selfId: '2354598417',
},
);
expect(recreated).toEqual({ changed: true, ok: true });
expect(service.runProcess).toHaveBeenCalledTimes(3);
expect(service.runProcess.mock.calls[1][2]).toContain(
"NAPCAT_QUICK_PASSWORD='qq-password'",
);
});
it('recreates quick-login runtime when password env is missing', async () => {
const containerRepository = {
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue({
dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-x',
id: 'container-1',
image: 'mlikiowa/napcat-docker:latest',
name: 'kt-qqbot-napcat-x',
reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse',
webuiPort: 6100,
webuiToken: 'token-x',
}),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string) =>
key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '',
),
} as any,
containerRepository as any,
{} as any,
new ToolsService(),
) as any;
service.runProcess = jest
.fn()
.mockResolvedValueOnce({ stderr: '', stdout: 'ACCOUNT=2354598417\n' })
.mockResolvedValueOnce({ stderr: '', stdout: '' })
.mockResolvedValueOnce({
stderr: '',
stdout: 'ACCOUNT=2354598417\nNAPCAT_QUICK_PASSWORD=qq-password\n',
});
const recreated = await service.ensureRuntimeLoginEnv(
{ id: 'container-1', name: 'kt-qqbot-napcat-x' },
{
loginPassword: 'qq-password',
selfId: '2354598417',
},
);
expect(recreated).toEqual({ changed: true, ok: true });
expect(service.runProcess).toHaveBeenCalledTimes(3);
expect(service.runProcess.mock.calls[1][2]).toContain(
"NAPCAT_QUICK_PASSWORD='qq-password'",
);
expect(containerRepository.update).toHaveBeenCalledWith(
{ id: 'container-1' },
expect.objectContaining({
lastError: null,
status: 'running',
}),
);
});
it('fails login env preparation when recreated container still misses password env', async () => {
const containerRepository = {
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue({
dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-x',
id: 'container-1',
image: 'mlikiowa/napcat-docker:latest',
name: 'kt-qqbot-napcat-x',
reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse',
webuiPort: 6100,
webuiToken: 'token-x',
}),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string) =>
key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '',
),
} as any,
containerRepository as any,
{} as any,
new ToolsService(),
) as any;
service.runProcess = jest
.fn()
.mockResolvedValueOnce({ stderr: '', stdout: 'ACCOUNT=2354598417\n' })
.mockResolvedValueOnce({ stderr: '', stdout: '' })
.mockResolvedValueOnce({ stderr: '', stdout: 'ACCOUNT=2354598417\n' });
const recreated = await service.ensureRuntimeLoginEnv(
{ id: 'container-1', name: 'kt-qqbot-napcat-x' },
{
loginPassword: 'qq-password',
selfId: '2354598417',
},
);
expect(recreated).toEqual({ changed: true, ok: false });
expect(service.runProcess).toHaveBeenCalledTimes(3);
expect(containerRepository.update).toHaveBeenCalledWith(
{ id: 'container-1' },
expect.objectContaining({
lastError: 'NapCat 运行态登录环境校验失败',
status: 'running',
}),
);
});
it('removes password env for a quick-login-only runtime stage', async () => {
const containerRepository = {
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue({
dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-x',
id: 'container-1',
image: 'mlikiowa/napcat-docker:latest',
name: 'kt-qqbot-napcat-x',
reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse',
webuiPort: 6100,
webuiToken: 'token-x',
}),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string) =>
key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '',
),
} as any,
containerRepository as any,
{} as any,
new ToolsService(),
) as any;
service.runProcess = jest
.fn()
.mockResolvedValueOnce({
stderr: '',
stdout: 'ACCOUNT=2354598417\nNAPCAT_QUICK_PASSWORD=old-password\n',
})
.mockResolvedValueOnce({ stderr: '', stdout: '' })
.mockResolvedValueOnce({ stderr: '', stdout: 'ACCOUNT=2354598417\n' });
const recreated = await service.ensureRuntimeLoginEnv(
{ id: 'container-1', name: 'kt-qqbot-napcat-x' },
{
clearLoginPassword: true,
selfId: '2354598417',
},
);
expect(recreated).toEqual({ changed: true, ok: true });
expect(service.runProcess.mock.calls[1][2]).not.toContain(
'NAPCAT_QUICK_PASSWORD=',
);
});
it('requires an explicit NapCat image when creating a managed container', async () => {
const containerRepository = {
find: jest.fn().mockResolvedValue([]),
save: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string) => {
const values: Record<string, string> = {
QQBOT_NAPCAT_CONTAINER_MODE: 'ssh',
QQBOT_NAPCAT_SSH_TARGET: 'nas',
};
return values[key] || '';
}),
} as any,
containerRepository as any,
{} as any,
new ToolsService(),
) as any;
await expect(service.createManagedContainer('1914728559')).rejects.toThrow(
'NapCat 镜像未配置,请先设置 QQBOT_NAPCAT_IMAGE',
);
expect(containerRepository.save).not.toHaveBeenCalled();
});
});