upsert / list / getDefault / setDefault / getAccessToken / getRefreshToken / delete. UNIQUE(provider, twitch_user_id) via ON CONFLICT DO UPDATE. setDefault ist provider-scoped (genau ein default pro provider). Scopes als JSON-Array serialisiert. 126 unit tests gruen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
121 lines
5.1 KiB
TypeScript
121 lines
5.1 KiB
TypeScript
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { openDatabase, type DbHandle } from '../infra/db';
|
|
import { MemorySecureStorage } from '../infra/secure-storage';
|
|
import { createTokenStore, type TokenStore } from './token-store';
|
|
|
|
let tmpDir: string;
|
|
let db: DbHandle;
|
|
let store: TokenStore;
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tokens-'));
|
|
db = openDatabase(path.join(tmpDir, 'app.db'));
|
|
store = createTokenStore(db, new MemorySecureStorage());
|
|
});
|
|
afterEach(() => {
|
|
db.close();
|
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
});
|
|
|
|
describe('createTokenStore', () => {
|
|
test('upsert new account returns record with id > 0', () => {
|
|
const rec = store.upsert({
|
|
provider: 'twitch',
|
|
twitchUserId: 'u1',
|
|
login: 'alice',
|
|
accessToken: 'aaa.aaa.aaa',
|
|
});
|
|
expect(rec.id).toBeGreaterThan(0);
|
|
expect(rec.login).toBe('alice');
|
|
expect(rec.provider).toBe('twitch');
|
|
expect(rec.twitchUserId).toBe('u1');
|
|
});
|
|
|
|
test('upsert same (provider, twitch_user_id) updates, no duplicate row', () => {
|
|
store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'alice', accessToken: 't1' });
|
|
const updated = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'alice2', accessToken: 't2' });
|
|
expect(updated.login).toBe('alice2');
|
|
const all = store.list('twitch');
|
|
expect(all).toHaveLength(1);
|
|
expect(all[0].login).toBe('alice2');
|
|
});
|
|
|
|
test('list() returns all accounts, list(provider) filters', () => {
|
|
store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x' });
|
|
store.upsert({ provider: 'twitch', twitchUserId: 'u2', login: 'b', accessToken: 'y' });
|
|
store.upsert({ provider: 'youtube', twitchUserId: undefined, login: 'c', accessToken: 'z' });
|
|
expect(store.list()).toHaveLength(3);
|
|
expect(store.list('twitch')).toHaveLength(2);
|
|
expect(store.list('youtube')).toHaveLength(1);
|
|
});
|
|
|
|
test('getDefault returns null when nothing default', () => {
|
|
store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x' });
|
|
expect(store.getDefault('twitch')).toBeNull();
|
|
});
|
|
|
|
test('upsert with isDefault=true makes it default, demotes siblings', () => {
|
|
const a = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x', isDefault: true });
|
|
const b = store.upsert({ provider: 'twitch', twitchUserId: 'u2', login: 'b', accessToken: 'y', isDefault: true });
|
|
|
|
const def = store.getDefault('twitch');
|
|
expect(def?.id).toBe(b.id);
|
|
|
|
const aAgain = store.list('twitch').find(r => r.id === a.id);
|
|
expect(aAgain?.isDefault).toBe(false);
|
|
});
|
|
|
|
test('setDefault toggles is_default exclusivity within provider', () => {
|
|
const a = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x', isDefault: true });
|
|
const b = store.upsert({ provider: 'twitch', twitchUserId: 'u2', login: 'b', accessToken: 'y' });
|
|
|
|
store.setDefault(b.id);
|
|
expect(store.getDefault('twitch')?.id).toBe(b.id);
|
|
|
|
const aAgain = store.list('twitch').find(r => r.id === a.id);
|
|
expect(aAgain?.isDefault).toBe(false);
|
|
});
|
|
|
|
test('getAccessToken returns decrypted plaintext', () => {
|
|
const rec = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'super-secret-token' });
|
|
expect(store.getAccessToken(rec.id)).toBe('super-secret-token');
|
|
});
|
|
|
|
test('getRefreshToken returns null if not provided, value if provided', () => {
|
|
const noRefresh = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 't1' });
|
|
expect(store.getRefreshToken(noRefresh.id)).toBeNull();
|
|
|
|
const withRefresh = store.upsert({
|
|
provider: 'twitch', twitchUserId: 'u2', login: 'b',
|
|
accessToken: 't2', refreshToken: 'refresh-xyz',
|
|
});
|
|
expect(store.getRefreshToken(withRefresh.id)).toBe('refresh-xyz');
|
|
});
|
|
|
|
test('scopes roundtrip as array', () => {
|
|
const rec = store.upsert({
|
|
provider: 'twitch', twitchUserId: 'u1', login: 'a',
|
|
accessToken: 't', scopes: ['user:read:email', 'channel:read:subscriptions'],
|
|
});
|
|
expect(rec.scopes).toEqual(['user:read:email', 'channel:read:subscriptions']);
|
|
});
|
|
|
|
test('delete removes the record', () => {
|
|
const rec = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x' });
|
|
store.delete(rec.id);
|
|
expect(store.list('twitch')).toHaveLength(0);
|
|
expect(() => store.getAccessToken(rec.id)).toThrow();
|
|
});
|
|
|
|
test('expiresAt roundtrip', () => {
|
|
const future = Math.floor(Date.now() / 1000) + 3600;
|
|
const rec = store.upsert({
|
|
provider: 'twitch', twitchUserId: 'u1', login: 'a',
|
|
accessToken: 't', expiresAt: future,
|
|
});
|
|
expect(rec.expiresAt).toBe(future);
|
|
});
|
|
});
|