This commit is contained in:
pa
2026-03-10 17:44:15 +09:00
parent 17b582c904
commit ff1529920b
237 changed files with 419 additions and 419 deletions

View File

@@ -0,0 +1,30 @@
// Mock router to avoid transitive i18n.global error from columns.jsx
vi.mock('../../plugins/router.js', () => ({
router: { beforeEach: vi.fn(), push: vi.fn() },
initRouter: vi.fn()
}));
import { transformKey } from '../config.js';
describe('transformKey', () => {
test('lowercases and prefixes with config:', () => {
expect(transformKey('Foo')).toBe('config:foo');
});
test('handles already lowercase key', () => {
expect(transformKey('bar')).toBe('config:bar');
});
test('handles key with mixed case and numbers', () => {
expect(transformKey('MyKey123')).toBe('config:mykey123');
});
test('handles empty string', () => {
expect(transformKey('')).toBe('config:');
});
test('converts non-string values via String()', () => {
expect(transformKey(42)).toBe('config:42');
expect(transformKey(null)).toBe('config:null');
});
});

View File

@@ -0,0 +1,53 @@
import removeConfusables, { removeWhitespace } from '../confusables.js';
describe('removeConfusables', () => {
test('returns ASCII strings unchanged (fast path)', () => {
expect(removeConfusables('Hello World')).toBe('HelloWorld');
});
test('converts circled letters to ASCII', () => {
expect(removeConfusables('Ⓗⓔⓛⓛⓞ')).toBe('Hello');
});
test('converts fullwidth letters to ASCII', () => {
expect(removeConfusables('')).toBe('Hello');
});
test('converts Cyrillic confusables', () => {
// Cyrillic А, В, С look like Latin A, B, C
expect(removeConfusables('АВС')).toBe('ABC');
});
test('handles mixed confusables and normal chars', () => {
expect(removeConfusables('Ⓣest')).toBe('Test');
});
test('strips combining marks', () => {
// 'e' + combining acute accent → normalized then combining mark stripped → 'e'
const input = 'e\u0301';
const result = removeConfusables(input);
expect(result).toBe('e');
});
test('returns empty string for empty input', () => {
expect(removeConfusables('')).toBe('');
});
});
describe('removeWhitespace', () => {
test('removes regular spaces', () => {
expect(removeWhitespace('a b c')).toBe('abc');
});
test('removes tabs and newlines', () => {
expect(removeWhitespace('a\tb\nc')).toBe('abc');
});
test('returns string without whitespace unchanged', () => {
expect(removeWhitespace('abc')).toBe('abc');
});
test('returns empty string for empty input', () => {
expect(removeWhitespace('')).toBe('');
});
});

View File

@@ -0,0 +1,220 @@
import { LogWatcherService } from '../gameLog.js';
const svc = new LogWatcherService();
describe('parseRawGameLog', () => {
test('parses location type', () => {
const log = svc.parseRawGameLog('2024-01-01', 'location', [
'wrld_123:456',
'Test World'
]);
expect(log).toEqual({
dt: '2024-01-01',
type: 'location',
location: 'wrld_123:456',
worldName: 'Test World'
});
});
test('parses location-destination type', () => {
const log = svc.parseRawGameLog('2024-01-01', 'location-destination', [
'wrld_abc:789'
]);
expect(log).toEqual({
dt: '2024-01-01',
type: 'location-destination',
location: 'wrld_abc:789'
});
});
test('parses player-joined type', () => {
const log = svc.parseRawGameLog('2024-01-01', 'player-joined', [
'TestUser',
'usr_123'
]);
expect(log).toEqual({
dt: '2024-01-01',
type: 'player-joined',
displayName: 'TestUser',
userId: 'usr_123'
});
});
test('parses player-left type', () => {
const log = svc.parseRawGameLog('2024-01-01', 'player-left', [
'TestUser',
'usr_123'
]);
expect(log).toEqual({
dt: '2024-01-01',
type: 'player-left',
displayName: 'TestUser',
userId: 'usr_123'
});
});
test('parses notification type', () => {
const json = '{"type":"invite"}';
const log = svc.parseRawGameLog('2024-01-01', 'notification', [json]);
expect(log).toEqual({
dt: '2024-01-01',
type: 'notification',
json
});
});
test('parses event type', () => {
const log = svc.parseRawGameLog('2024-01-01', 'event', ['some-event']);
expect(log).toEqual({
dt: '2024-01-01',
type: 'event',
event: 'some-event'
});
});
test('parses video-play type', () => {
const log = svc.parseRawGameLog('2024-01-01', 'video-play', [
'https://example.com/video.mp4',
'Player1'
]);
expect(log).toEqual({
dt: '2024-01-01',
type: 'video-play',
videoUrl: 'https://example.com/video.mp4',
displayName: 'Player1'
});
});
test('parses resource-load-string type', () => {
const log = svc.parseRawGameLog('2024-01-01', 'resource-load-string', [
'https://example.com/res'
]);
expect(log).toEqual({
dt: '2024-01-01',
type: 'resource-load-string',
resourceUrl: 'https://example.com/res'
});
});
test('parses resource-load-image type', () => {
const log = svc.parseRawGameLog('2024-01-01', 'resource-load-image', [
'https://example.com/img.png'
]);
expect(log).toEqual({
dt: '2024-01-01',
type: 'resource-load-image',
resourceUrl: 'https://example.com/img.png'
});
});
test('parses avatar-change type', () => {
const log = svc.parseRawGameLog('2024-01-01', 'avatar-change', [
'User1',
'CoolAvatar'
]);
expect(log).toEqual({
dt: '2024-01-01',
type: 'avatar-change',
displayName: 'User1',
avatarName: 'CoolAvatar'
});
});
test('parses photon-id type', () => {
const log = svc.parseRawGameLog('2024-01-01', 'photon-id', [
'User1',
'42'
]);
expect(log).toEqual({
dt: '2024-01-01',
type: 'photon-id',
displayName: 'User1',
photonId: '42'
});
});
test('parses screenshot type', () => {
const log = svc.parseRawGameLog('2024-01-01', 'screenshot', [
'/path/to/screenshot.png'
]);
expect(log).toEqual({
dt: '2024-01-01',
type: 'screenshot',
screenshotPath: '/path/to/screenshot.png'
});
});
test('parses sticker-spawn type', () => {
const log = svc.parseRawGameLog('2024-01-01', 'sticker-spawn', [
'usr_abc',
'StickerUser',
'inv_123'
]);
expect(log).toEqual({
dt: '2024-01-01',
type: 'sticker-spawn',
userId: 'usr_abc',
displayName: 'StickerUser',
inventoryId: 'inv_123'
});
});
test('parses video-sync type', () => {
const log = svc.parseRawGameLog('2024-01-01', 'video-sync', [
'123.456'
]);
expect(log).toEqual({
dt: '2024-01-01',
type: 'video-sync',
timestamp: '123.456'
});
});
test('parses vrcx type', () => {
const log = svc.parseRawGameLog('2024-01-01', 'vrcx', ['some-data']);
expect(log).toEqual({
dt: '2024-01-01',
type: 'vrcx',
data: 'some-data'
});
});
test('parses api-request type', () => {
const log = svc.parseRawGameLog('2024-01-01', 'api-request', [
'https://api.vrchat.cloud/api/1/users'
]);
expect(log).toEqual({
dt: '2024-01-01',
type: 'api-request',
url: 'https://api.vrchat.cloud/api/1/users'
});
});
test('parses udon-exception type', () => {
const log = svc.parseRawGameLog('2024-01-01', 'udon-exception', [
'NullRef'
]);
expect(log).toEqual({
dt: '2024-01-01',
type: 'udon-exception',
data: 'NullRef'
});
});
test('handles types with no extra fields', () => {
for (const type of [
'portal-spawn',
'vrc-quit',
'openvr-init',
'desktop-mode'
]) {
const log = svc.parseRawGameLog('2024-01-01', type, []);
expect(log).toEqual({ dt: '2024-01-01', type });
}
});
test('handles unknown type gracefully', () => {
const log = svc.parseRawGameLog('2024-01-01', 'unknown-type', ['foo']);
expect(log).toEqual({ dt: '2024-01-01', type: 'unknown-type' });
});
});

View File

@@ -0,0 +1,305 @@
// Mock router to avoid transitive i18n.global error from columns.jsx
vi.mock('../../plugins/router.js', () => ({
router: { beforeEach: vi.fn(), push: vi.fn() },
initRouter: vi.fn()
}));
import {
buildRequestInit,
parseResponse,
processBulk,
shouldIgnoreError
} from '../request.js';
describe('buildRequestInit', () => {
test('builds GET request with default method', () => {
const init = buildRequestInit('users/usr_123');
expect(init.method).toBe('GET');
expect(init.url).toContain('users/usr_123');
});
test('serializes GET params into URL search params', () => {
const init = buildRequestInit('users', {
params: { n: 50, offset: 0 }
});
expect(init.url).toContain('n=50');
expect(init.url).toContain('offset=0');
});
test('does not set body for GET requests', () => {
const init = buildRequestInit('users', {
params: { n: 50 }
});
expect(init.body).toBeUndefined();
});
test('sets JSON content-type and body for POST', () => {
const init = buildRequestInit('auth/login', {
method: 'POST',
params: { username: 'test' }
});
expect(init.headers['Content-Type']).toBe(
'application/json;charset=utf-8'
);
expect(init.body).toBe(JSON.stringify({ username: 'test' }));
});
test('sets empty body when POST has no params', () => {
const init = buildRequestInit('auth/logout', { method: 'POST' });
expect(init.body).toBe('{}');
});
test('preserves custom headers in POST', () => {
const init = buildRequestInit('users', {
method: 'PUT',
headers: { 'X-Custom': 'value' },
params: { a: 1 }
});
expect(init.headers['Content-Type']).toBe(
'application/json;charset=utf-8'
);
expect(init.headers['X-Custom']).toBe('value');
});
test('skips body/headers for upload requests', () => {
const init = buildRequestInit('file/upload', {
method: 'PUT',
uploadImage: true,
params: { something: 1 }
});
expect(init.body).toBeUndefined();
expect(init.headers).toBeUndefined();
});
test('skips body/headers for uploadFilePUT', () => {
const init = buildRequestInit('file/upload', {
method: 'PUT',
uploadFilePUT: true
});
expect(init.body).toBeUndefined();
});
test('skips body/headers for uploadImageLegacy', () => {
const init = buildRequestInit('file/upload', {
method: 'POST',
uploadImageLegacy: true
});
expect(init.body).toBeUndefined();
});
test('passes through extra options', () => {
const init = buildRequestInit('test', {
method: 'DELETE',
inviteId: 'inv_123'
});
expect(init.method).toBe('DELETE');
expect(init.inviteId).toBe('inv_123');
});
});
describe('parseResponse', () => {
test('returns response unchanged when no data', () => {
const response = { status: 200 };
expect(parseResponse(response)).toEqual({ status: 200 });
});
test('parses valid JSON data', () => {
const response = {
status: 200,
data: JSON.stringify({ name: 'test' })
};
const result = parseResponse(response);
expect(result.data).toEqual({ name: 'test' });
expect(result.hasApiError).toBeUndefined();
expect(result.parseError).toBeUndefined();
});
test('detects API error in response data', () => {
const response = {
status: 404,
data: JSON.stringify({
error: { status_code: 404, message: 'Not found' }
})
};
const result = parseResponse(response);
expect(result.hasApiError).toBe(true);
expect(result.data.error.message).toBe('Not found');
});
test('flags parse error for invalid JSON', () => {
const response = { status: 200, data: 'not valid json{{{' };
const result = parseResponse(response);
expect(result.parseError).toBe(true);
expect(result.status).toBe(200);
});
test('handles empty string data', () => {
const response = { status: 200, data: '' };
const result = parseResponse(response);
// empty string is falsy, so treated as no data
expect(result).toEqual({ status: 200, data: '' });
});
test('handles null data', () => {
const response = { status: 200, data: null };
const result = parseResponse(response);
expect(result).toEqual({ status: 200, data: null });
});
});
describe('shouldIgnoreError', () => {
test.each([
[404, 'users/usr_123'],
[404, 'worlds/wrld_123'],
[404, 'avatars/avtr_123'],
[404, 'groups/grp_123'],
[404, 'file/file_123'],
[-1, 'users/usr_123']
])('ignores %i for single-segment resource %s', (code, endpoint) => {
expect(shouldIgnoreError(code, endpoint)).toBe(true);
});
test('does NOT ignore nested resource paths', () => {
expect(shouldIgnoreError(404, 'users/usr_123/friends')).toBe(false);
});
test('does NOT ignore 403 for resource lookups', () => {
expect(shouldIgnoreError(403, 'users/usr_123')).toBe(false);
});
test.each([403, 404, -1])('ignores %i for instances/ endpoints', (code) => {
expect(shouldIgnoreError(code, 'instances/wrld_123:456')).toBe(true);
});
test('ignores any code for analysis/ endpoints', () => {
expect(shouldIgnoreError(500, 'analysis/something')).toBe(true);
expect(shouldIgnoreError(200, 'analysis/data')).toBe(true);
});
test.each([403, -1])('ignores %i for /mutuals endpoints', (code) => {
expect(shouldIgnoreError(code, 'users/usr_123/mutuals')).toBe(true);
});
test('does NOT ignore 404 for /mutuals', () => {
expect(shouldIgnoreError(404, 'users/usr_123/mutuals')).toBe(false);
});
test('returns false for unmatched patterns', () => {
expect(shouldIgnoreError(500, 'auth/login')).toBe(false);
expect(shouldIgnoreError(200, 'config')).toBe(false);
});
test('handles undefined endpoint', () => {
expect(shouldIgnoreError(404, undefined)).toBe(false);
});
});
describe('processBulk', () => {
test('fetches all pages until empty batch', async () => {
const pages = [{ json: [1, 2, 3] }, { json: [4, 5] }, { json: [] }];
let call = 0;
const fn = vi.fn(() => Promise.resolve(pages[call++]));
const handle = vi.fn();
const done = vi.fn();
await processBulk({ fn, params: { n: 3 }, handle, done });
expect(fn).toHaveBeenCalledTimes(3);
expect(handle).toHaveBeenCalledTimes(3);
expect(done).toHaveBeenCalledWith(true);
});
test('stops when N > 0 limit is reached', async () => {
const fn = vi.fn(() => Promise.resolve({ json: [1, 2, 3] }));
const done = vi.fn();
await processBulk({ fn, params: { n: 3 }, N: 5, done });
expect(fn).toHaveBeenCalledTimes(2);
expect(done).toHaveBeenCalledWith(true);
});
test('stops when N = 0 and batch < pageSize', async () => {
const pages = [{ json: [1, 2, 3] }, { json: [4] }];
let call = 0;
const fn = vi.fn(() => Promise.resolve(pages[call++]));
const done = vi.fn();
await processBulk({ fn, params: { n: 3 }, N: 0, done });
expect(fn).toHaveBeenCalledTimes(2);
expect(done).toHaveBeenCalledWith(true);
});
test('stops when hasNext is false', async () => {
const fn = vi.fn(() =>
Promise.resolve({ json: [1, 2, 3], hasNext: false })
);
const done = vi.fn();
await processBulk({ fn, params: { n: 3 }, N: -1, done });
expect(fn).toHaveBeenCalledTimes(1);
expect(done).toHaveBeenCalledWith(true);
});
test('supports result.results array format', async () => {
const pages = [{ results: [1, 2] }, { results: [] }];
let call = 0;
const fn = vi.fn(() => Promise.resolve(pages[call++]));
const done = vi.fn();
await processBulk({ fn, params: { n: 5 }, done });
expect(fn).toHaveBeenCalledTimes(2);
expect(done).toHaveBeenCalledWith(true);
});
test('calls done(false) when fn throws', async () => {
const fn = vi.fn(() => Promise.reject(new Error('network error')));
const done = vi.fn();
await processBulk({ fn, params: { n: 5 }, done });
expect(done).toHaveBeenCalledWith(false);
});
test('increments offset correctly', async () => {
const pages = [{ json: [1, 2, 3] }, { json: [4, 5] }, { json: [] }];
let call = 0;
const offsets = [];
const fn = vi.fn((params) => {
offsets.push(params.offset);
const result = pages[call++];
return Promise.resolve(result);
});
await processBulk({ fn, params: { n: 3 } });
expect(offsets).toEqual([0, 3, 5]);
});
test('returns early if fn is not a function', async () => {
const done = vi.fn();
await processBulk({ fn: null, done });
expect(done).not.toHaveBeenCalled();
});
test('uses custom limitParam', async () => {
const pages = [{ json: [1, 2] }, { json: [1] }];
let call = 0;
const fn = vi.fn(() => Promise.resolve(pages[call++]));
const done = vi.fn();
await processBulk({
fn,
params: { limit: 2 },
limitParam: 'limit',
N: 0,
done
});
expect(fn).toHaveBeenCalledTimes(2);
expect(done).toHaveBeenCalledWith(true);
});
});

View File

@@ -0,0 +1,97 @@
import security, {
hexToUint8Array,
stdAESKey,
uint8ArrayToHex
} from '../security.js';
describe('hexToUint8Array', () => {
test('converts hex string to Uint8Array', () => {
const result = hexToUint8Array('0a1bff');
expect(result).toEqual(new Uint8Array([0x0a, 0x1b, 0xff]));
});
test('converts empty-ish input', () => {
const result = hexToUint8Array('00');
expect(result).toEqual(new Uint8Array([0]));
});
test('returns null for empty string', () => {
expect(hexToUint8Array('')).toBeNull();
});
});
describe('uint8ArrayToHex', () => {
test('converts Uint8Array to hex string', () => {
const result = uint8ArrayToHex(new Uint8Array([0x0a, 0x1b, 0xff]));
expect(result).toBe('0a1bff');
});
test('pads single-digit hex values', () => {
const result = uint8ArrayToHex(new Uint8Array([0, 1, 2]));
expect(result).toBe('000102');
});
test('converts empty array', () => {
expect(uint8ArrayToHex(new Uint8Array([]))).toBe('');
});
});
describe('hex round-trip', () => {
test('uint8Array → hex → uint8Array preserves data', () => {
const original = new Uint8Array([0, 127, 255, 42, 1]);
const hex = uint8ArrayToHex(original);
const restored = hexToUint8Array(hex);
expect(restored).toEqual(original);
});
});
describe('stdAESKey', () => {
test('pads short key to 32 bytes', () => {
const result = stdAESKey('abc');
expect(result.length).toBe(32);
// First 3 bytes should be 'abc'
expect(result[0]).toBe('a'.charCodeAt(0));
expect(result[1]).toBe('b'.charCodeAt(0));
expect(result[2]).toBe('c'.charCodeAt(0));
});
test('truncates long key to 32 bytes', () => {
const longKey = 'a'.repeat(64);
const result = stdAESKey(longKey);
expect(result.length).toBe(32);
});
test('32-byte key stays unchanged', () => {
const key = 'abcdefghijklmnopqrstuvwxyz012345'; // exactly 32 chars
const result = stdAESKey(key);
expect(result.length).toBe(32);
const expected = new TextEncoder().encode(key);
expect(result).toEqual(expected);
});
});
describe('encrypt / decrypt round-trip', () => {
test('encrypts and decrypts plaintext correctly', async () => {
const plaintext = 'Hello, VRCX!';
const key = 'my-secret-key';
const ciphertext = await security.encrypt(plaintext, key);
expect(typeof ciphertext).toBe('string');
expect(ciphertext.length).toBeGreaterThan(0);
const decrypted = await security.decrypt(ciphertext, key);
expect(decrypted).toBe(plaintext);
});
test('different keys produce different ciphertext', async () => {
const plaintext = 'secret data';
const ct1 = await security.encrypt(plaintext, 'key1');
const ct2 = await security.encrypt(plaintext, 'key2');
expect(ct1).not.toBe(ct2);
});
test('decrypt returns empty string for empty input', async () => {
const result = await security.decrypt('', 'key');
expect(result).toBe('');
});
});