XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEServerManager

【Java】我的世界XFE服务器管理器

公开
关注 0 Fork 0 Star 0
UTF-8
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ApiClient, ApiError } from './api';
import type { TriggerGroup } from '../types';

describe('ApiClient', () => {
  afterEach(() => {
    vi.restoreAllMocks();
    vi.unstubAllGlobals();
    localStorage.removeItem('xfesm.locale');
  });

  it('uses same-origin credentials and correlation headers', async () => {
    const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ data: { playersOnline: 3 } }), {
      status: 200,
      headers: { 'content-type': 'application/json' },
    }));
    const client = new ApiClient('/api/v1/');

    await expect(client.request<{ playersOnline: number }>('/status')).resolves.toEqual({ playersOnline: 3 });
    expect(fetchMock).toHaveBeenCalledOnce();
    const [url, options] = fetchMock.mock.calls[0];
    expect(url).toBe('/api/v1/status');
    expect(options?.credentials).toBe('same-origin');
    expect(new Headers(options?.headers).get('X-XFESM-Request-ID')).toBeTruthy();
  });

  it('adds CSRF and idempotency headers to mutations', async () => {
    sessionStorage.setItem('xfesm.csrf', 'csrf-token');
    const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ id: 'op-1' }), {
      status: 202,
      headers: { 'content-type': 'application/json' },
    }));
    const client = new ApiClient();

    await client.executeConsole('save-all flush', 'maintenance');
    const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
    expect(headers.get('X-CSRF-Token')).toBe('csrf-token');
    expect(headers.get('Idempotency-Key')).toBeTruthy();
    expect(fetchMock.mock.calls[0][1]?.body).toBe(JSON.stringify({ command: 'save-all flush', reason: 'maintenance' }));
  });

  it('posts visual trigger programs without converting them to script', async () => {
    const program = {
      event: { type: 'player.join', configuration: {} },
      conditionMode: 'all' as const,
      conditions: [{ field: 'player.name', operator: 'matches', value: '\\p{L}+' }],
      actions: [{ type: 'broadcast', parameters: { message: 'Hello' } }],
    };
    const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
      valid: true, ...program,
    }), { status: 200, headers: { 'content-type': 'application/json' } }));

    await new ApiClient().validateVisualTrigger(program);

    expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/triggers/validate');
    expect(fetchMock.mock.calls[0][1]?.body).toBe(JSON.stringify(program));
  });

  it('retries an ambiguous trigger timeout once with the same idempotency key', async () => {
    const fetchMock = vi.spyOn(globalThis, 'fetch')
      .mockResolvedValueOnce(new Response(JSON.stringify({
        title: 'Operation still pending', status: 504,
      }), { status: 504, headers: { 'content-type': 'application/problem+json' } }))
      .mockResolvedValueOnce(new Response(JSON.stringify({ groups: [], totalTriggers: 0 }), {
        status: 201, headers: { 'content-type': 'application/json' },
      }));

    await expect(new ApiClient().createTriggerGroup({
      name: 'Scheduled notices', description: '', enabled: true,
    })).resolves.toEqual({ groups: [], totalTriggers: 0 });

    expect(fetchMock).toHaveBeenCalledTimes(2);
    const keys = fetchMock.mock.calls.map(([, init]) => new Headers(init?.headers).get('Idempotency-Key'));
    expect(keys[0]).toBeTruthy();
    expect(keys[1]).toBe(keys[0]);
  });

  it('preserves the trigger operation key across reauthentication', async () => {
    const fetchMock = vi.spyOn(globalThis, 'fetch')
      .mockResolvedValueOnce(new Response(JSON.stringify({ status: 428 }), {
        status: 428, headers: { 'content-type': 'application/problem+json' },
      }))
      .mockResolvedValueOnce(new Response(JSON.stringify({ groups: [], totalTriggers: 0 }), {
        status: 200, headers: { 'content-type': 'application/json' },
      }));
    const client = new ApiClient();
    const reauthenticate = vi.fn().mockResolvedValue(undefined);
    client.setReauthenticationHandler(reauthenticate);

    await client.createTriggerGroup({ name: 'Join flow', description: '', enabled: true });

    expect(reauthenticate).toHaveBeenCalledOnce();
    const keys = fetchMock.mock.calls.map(([, init]) => new Headers(init?.headers).get('Idempotency-Key'));
    expect(keys[1]).toBe(keys[0]);
  });

  it('retries a trigger transport failure once with the same idempotency key', async () => {
    const fetchMock = vi.spyOn(globalThis, 'fetch')
      .mockRejectedValueOnce(new TypeError('connection reset'))
      .mockResolvedValueOnce(new Response(JSON.stringify({ groups: [], totalTriggers: 0 }), {
        status: 200, headers: { 'content-type': 'application/json' },
      }));

    await new ApiClient().createTriggerGroup({ name: 'Events', description: '', enabled: true });

    expect(fetchMock).toHaveBeenCalledTimes(2);
    const keys = fetchMock.mock.calls.map(([, init]) => new Headers(init?.headers).get('Idempotency-Key'));
    expect(keys[1]).toBe(keys[0]);
  });

  it('does not retry ambiguous failures for mutations that are not explicitly idempotent', async () => {
    const fetchMock = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('offline'));

    await expect(new ApiClient().login('owner', 'password')).rejects.toBeInstanceOf(ApiError);
    expect(fetchMock).toHaveBeenCalledOnce();
  });

  it('surfaces RFC 7807 problem details', async () => {
    vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
      title: 'Denied', status: 403, detail: 'Recent authentication is required.', requestId: 'req-7',
    }), { status: 403, headers: { 'content-type': 'application/problem+json' } }));
    const client = new ApiClient();

    const error = await client.settings().catch((cause: unknown) => cause);
    expect(error).toBeInstanceOf(ApiError);
    expect(error).toMatchObject({ status: 403, message: 'Recent authentication is required.' });
  });

  it('shows Chinese-only safe API errors when the UI locale is Chinese', async () => {
    localStorage.setItem('xfesm.locale', 'zh-CN');
    vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
      title: 'Forbidden', status: 403, detail: 'Recent authentication is required.', requestId: 'req-zh',
    }), { status: 403, headers: { 'content-type': 'application/problem+json' } }));

    const error = await new ApiClient().settings().catch((cause: unknown) => cause);
    expect(error).toMatchObject({
      status: 403,
      message: '没有执行此操作的权限(请求 ID:req-zh)',
    });
  });

  it('selects one language from bilingual operation status messages', async () => {
    localStorage.setItem('xfesm.locale', 'zh-CN');
    vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
      kind: 'settings.update', state: 'SUCCEEDED', message: 'Settings applied / 设置已生效',
    }), { status: 200, headers: { 'content-type': 'application/json' } }));

    await expect(new ApiClient().request<{ message: string }>('settings-test'))
      .resolves.toMatchObject({ message: '设置已生效' });
  });

  it('normalizes bare arrays into paged results', async () => {
    vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify([{ uuid: 'u1', name: 'Alex', online: true }]), {
      status: 200,
      headers: { 'content-type': 'application/json' },
    }));
    const client = new ApiClient();
    await expect(client.players()).resolves.toEqual({ items: [{ uuid: 'u1', name: 'Alex', online: true }] });
  });

  it('loads the live server and trigger command catalog for policy rules', async () => {
    const response = { commands: [{
      id: 'minecraft:welcome', root: 'welcome', usages: ['/welcome <target>'],
      aliases: ['welcome'], source: 'trigger' as const, triggerId: 'trigger-1', arguments: ['target'],
    }] };
    const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(response), {
      status: 200,
      headers: { 'content-type': 'application/json' },
    }));

    await expect(new ApiClient().policyCommands()).resolves.toEqual(response);
    expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/policies/commands');
    expect(fetchMock.mock.calls[0][1]?.method).toBe('GET');
  });

  it('uses the revisioned single-slot inventory contract without raw editing', async () => {
    const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
      id: 'inventory-op', state: 'SUCCEEDED', revision: 8, diffs: [],
    }), { status: 200, headers: { 'content-type': 'application/json' } }));
    const client = new ApiClient();

    await client.updatePlayerInventory('player/id', 'ender-chest', 7, {
      slot: 3,
      itemId: 'minecraft:diamond',
      count: 4,
      structuredData: {},
      opaqueDataPresent: false,
      opaqueDataFingerprint: '',
    }, 'Owner approved replacement');

    expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/players/player%2Fid/ender-chest');
    expect(fetchMock.mock.calls[0][1]?.method).toBe('PATCH');
    expect(fetchMock.mock.calls[0][1]?.body).toBe(JSON.stringify({
      expectedRevision: 7,
      reason: 'Owner approved replacement',
      rawEditingRequested: false,
      updates: [{ slot: 3, itemId: 'minecraft:diamond', count: 4, structuredData: {} }],
    }));
  });

  it('sends only editable group fields and the expected revision when updating a trigger group', async () => {
    const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
      groups: [], totalTriggers: 0,
    }), { status: 200, headers: { 'content-type': 'application/json' } }));
    const group: TriggerGroup = {
      id: 'group/id', name: 'Automations', description: 'Managed group', enabled: false, revision: 7,
      createdBy: 'owner', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-02T00:00:00Z',
      migrated: true, triggers: [],
    };

    await new ApiClient().updateTriggerGroup(group);

    expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/trigger-groups/group%2Fid');
    expect(fetchMock.mock.calls[0][1]?.body).toBe(JSON.stringify({
      name: 'Automations', description: 'Managed group', enabled: false, expectedRevision: 7,
    }));
  });

  it('uses the server authentication contract for setup, TOTP, and reauthentication', async () => {
    const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
      const path = String(input);
      if (path.endsWith('/auth/totp/enrollment')) {
        return new Response(JSON.stringify({ secret: 'SECRET', provisioningUri: 'otpauth://totp/x', expiresAt: '2026-01-01T00:00:00Z' }), { status: 200, headers: { 'content-type': 'application/json' } });
      }
      if (path.endsWith('/auth/totp/confirm')) {
        return new Response(JSON.stringify({ recoveryCodes: ['one'] }), { status: 200, headers: { 'content-type': 'application/json' } });
      }
      if (path.endsWith('/auth/reauthenticate')) return new Response(null, { status: 204 });
      return new Response(JSON.stringify({ authenticated: true }), { status: 201, headers: { 'content-type': 'application/json' } });
    });
    const client = new ApiClient();

    await client.setup('bootstrap', 'owner', 'strong-password');
    await client.login('owner', 'strong-password', '123456');
    await client.login('owner', 'strong-password', 'recover-code', true);
    await client.beginTotpEnrollment('strong-password');
    await client.confirmTotpEnrollment('123456');
    await client.reauthenticate('strong-password', '654321');

    expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
      '/api/v1/setup',
      '/api/v1/auth/login',
      '/api/v1/auth/login',
      '/api/v1/auth/totp/enrollment',
      '/api/v1/auth/totp/confirm',
      '/api/v1/auth/reauthenticate',
    ]);
    expect(fetchMock.mock.calls[0][1]?.body).toBe(JSON.stringify({ bootstrapToken: 'bootstrap', username: 'owner', password: 'strong-password' }));
    expect(fetchMock.mock.calls[1][1]?.body).toBe(JSON.stringify({ username: 'owner', password: 'strong-password', totp: '123456' }));
    expect(fetchMock.mock.calls[2][1]?.body).toBe(JSON.stringify({ username: 'owner', password: 'strong-password', recoveryCode: 'recover-code' }));
    expect(fetchMock.mock.calls[5][1]?.body).toBe(JSON.stringify({ password: 'strong-password', totp: '654321' }));
  });

  it('uses the account-management REST contract', async () => {
    const account = { id: 'account/id', username: 'helper', role: 'helper' as const, totpEnabled: false, disabled: false, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', lastLoginAt: null };
    const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
      if (init?.method === 'DELETE') return new Response(null, { status: 204 });
      return new Response(JSON.stringify(init?.method === 'GET' || !init?.method ? [account] : account), { status: init?.method === 'POST' ? 201 : 200, headers: { 'content-type': 'application/json' } });
    });
    const client = new ApiClient();

    await client.accounts();
    await client.createAccount('helper', 'initial-password', 'helper');
    await client.updateAccount('account/id', { role: 'moderator', disabled: true });
    await client.deleteAccount('account/id');

    expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
      '/api/v1/accounts', '/api/v1/accounts', '/api/v1/accounts/account%2Fid', '/api/v1/accounts/account%2Fid',
    ]);
    expect(fetchMock.mock.calls[1][1]?.body).toBe(JSON.stringify({ username: 'helper', password: 'initial-password', role: 'helper' }));
    expect(fetchMock.mock.calls[2][1]?.body).toBe(JSON.stringify({ role: 'moderator', disabled: true }));
    expect(fetchMock.mock.calls[3][1]?.method).toBe('DELETE');
  });

  it('keeps EventSource alive for browser-managed reconnects and closes it on cleanup', () => {
    class TestEventSource {
      static latest?: TestEventSource;
      readonly close = vi.fn();
      readonly eventTypes: string[] = [];
      onopen: ((event: Event) => void) | null = null;
      onerror: ((event: Event) => void) | null = null;
      onmessage: ((event: MessageEvent<string>) => void) | null = null;
      constructor(readonly url: string, readonly options: EventSourceInit) {
        TestEventSource.latest = this;
      }
      addEventListener(type: string, _listener: EventListenerOrEventListenerObject): void { this.eventTypes.push(type); }
    }
    vi.stubGlobal('EventSource', TestEventSource);
    const onState = vi.fn();
    const unsubscribe = new ApiClient().subscribe(vi.fn(), onState);
    const source = TestEventSource.latest!;

    source.onerror?.(new Event('error'));
    expect(onState).toHaveBeenCalledWith('error');
    expect(source.eventTypes).toContain('triggers-changed');
    expect(source.close).not.toHaveBeenCalled();
    unsubscribe();
    expect(source.close).toHaveBeenCalledOnce();
  });
});