feat(dm-dashboard): Wave 1 — auth fixes, DB schema, Open5e interfaces, AI clients
- Fix auth anti-patterns (JWT, OAuth, logout, middleware, url.origin) - Extend DB schema with tier, npcs, counters, translations, notes, initiative - Expand Open5e TypeScript interfaces (Monster, Spell) to full V2 - Build OpenRouter API client for FREE tier - Build Kimi API client for PRO tier - Add comprehensive tests for all modules
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export function defineMiddleware(fn: unknown) {
|
||||
return fn;
|
||||
}
|
||||
@@ -197,7 +197,7 @@ describe("Open5e Client", () => {
|
||||
},
|
||||
];
|
||||
mockStorage.setItem(
|
||||
"open5e:monsters:dragon",
|
||||
"open5e:v2:monsters:dragon",
|
||||
JSON.stringify({ data: cached, timestamp: Date.now() })
|
||||
);
|
||||
|
||||
@@ -230,7 +230,7 @@ describe("Open5e Client", () => {
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
await searchMonsters("new");
|
||||
const stored = mockStorage.getItem("open5e:monsters:new");
|
||||
const stored = mockStorage.getItem("open5e:v2:monsters:new");
|
||||
expect(stored).not.toBeNull();
|
||||
const parsed = JSON.parse(stored!);
|
||||
expect(parsed.data).toEqual(mockData.results);
|
||||
@@ -249,7 +249,7 @@ describe("Open5e Client", () => {
|
||||
},
|
||||
];
|
||||
mockStorage.setItem(
|
||||
"open5e:monsters:dragon",
|
||||
"open5e:v2:monsters:dragon",
|
||||
JSON.stringify({ data: cached, timestamp: Date.now() })
|
||||
);
|
||||
|
||||
@@ -277,7 +277,7 @@ describe("Open5e Client", () => {
|
||||
},
|
||||
];
|
||||
mockStorage.setItem(
|
||||
"open5e:monsters:dragon",
|
||||
"open5e:v2:monsters:dragon",
|
||||
JSON.stringify({ data: staleData, timestamp: Date.now() - 25 * 60 * 60 * 1000 })
|
||||
);
|
||||
|
||||
@@ -318,10 +318,10 @@ describe("Open5e Client", () => {
|
||||
await searchSpells("fire");
|
||||
await getSpell("fireball");
|
||||
|
||||
expect(mockStorage.getItem("open5e:monsters:dragon")).not.toBeNull();
|
||||
expect(mockStorage.getItem("open5e:monster:goblin")).not.toBeNull();
|
||||
expect(mockStorage.getItem("open5e:spells:fire")).not.toBeNull();
|
||||
expect(mockStorage.getItem("open5e:spell:fireball")).not.toBeNull();
|
||||
expect(mockStorage.getItem("open5e:v2:monsters:dragon")).not.toBeNull();
|
||||
expect(mockStorage.getItem("open5e:v2:monster:goblin")).not.toBeNull();
|
||||
expect(mockStorage.getItem("open5e:v2:spells:fire")).not.toBeNull();
|
||||
expect(mockStorage.getItem("open5e:v2:spell:fireball")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Monster, Spell } from "../src/lib/open5e/client";
|
||||
|
||||
// Type-level assertions: verify interfaces contain all required fields.
|
||||
// If a listed key is missing from the interface, TypeScript will error at compile time.
|
||||
type AssertKeys<T, K extends keyof T> = K;
|
||||
|
||||
type _MonsterHasAllFields = AssertKeys<
|
||||
Monster,
|
||||
| "name"
|
||||
| "key"
|
||||
| "challenge_rating_decimal"
|
||||
| "type"
|
||||
| "hit_points"
|
||||
| "armor_class"
|
||||
| "speed"
|
||||
| "actions"
|
||||
| "special_abilities"
|
||||
| "legendary_actions"
|
||||
| "senses"
|
||||
| "languages"
|
||||
| "strength"
|
||||
| "dexterity"
|
||||
| "constitution"
|
||||
| "intelligence"
|
||||
| "wisdom"
|
||||
| "charisma"
|
||||
| "size"
|
||||
| "subtype"
|
||||
| "alignment"
|
||||
| "damage_immunities"
|
||||
| "damage_resistances"
|
||||
| "damage_vulnerabilities"
|
||||
| "condition_immunities"
|
||||
>;
|
||||
|
||||
type _SpellHasAllFields = AssertKeys<
|
||||
Spell,
|
||||
| "name"
|
||||
| "key"
|
||||
| "level"
|
||||
| "school"
|
||||
| "casting_time"
|
||||
| "range"
|
||||
| "components"
|
||||
| "duration"
|
||||
| "desc"
|
||||
| "higher_level"
|
||||
| "ritual"
|
||||
| "concentration"
|
||||
| "classes"
|
||||
>;
|
||||
|
||||
// Runtime dummy test so Vitest recognizes this file.
|
||||
describe("Open5e type assertions", () => {
|
||||
it("Monster interface contains all required keys at compile time", () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("Spell interface contains all required keys at compile time", () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getTableConfig } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
users,
|
||||
sessions,
|
||||
npcs,
|
||||
generationCounters,
|
||||
translations,
|
||||
notes,
|
||||
initiativeSessions,
|
||||
} from '../src/db/schema';
|
||||
|
||||
describe('Database Schema', () => {
|
||||
describe('users table', () => {
|
||||
it('has all required columns', () => {
|
||||
const config = getTableConfig(users);
|
||||
const columnNames = config.columns.map((c) => c.name);
|
||||
expect(columnNames).toEqual(
|
||||
expect.arrayContaining([
|
||||
'id',
|
||||
'vk_id',
|
||||
'yandex_id',
|
||||
'email',
|
||||
'name',
|
||||
'avatar',
|
||||
'tier',
|
||||
'boosty_verified_at',
|
||||
'created_at',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('has tier column with default free', () => {
|
||||
const config = getTableConfig(users);
|
||||
const tier = config.columns.find((c) => c.name === 'tier');
|
||||
expect(tier).toBeDefined();
|
||||
});
|
||||
|
||||
it('has boosty_verified_at column', () => {
|
||||
const config = getTableConfig(users);
|
||||
const col = config.columns.find((c) => c.name === 'boosty_verified_at');
|
||||
expect(col).toBeDefined();
|
||||
});
|
||||
|
||||
it('has tier check constraint', () => {
|
||||
const config = getTableConfig(users);
|
||||
const checkNames = config.checks.map((c) => c.name);
|
||||
expect(checkNames).toContain('users_tier_check');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessions table', () => {
|
||||
it('has required columns', () => {
|
||||
const config = getTableConfig(sessions);
|
||||
const columnNames = config.columns.map((c) => c.name);
|
||||
expect(columnNames).toEqual(
|
||||
expect.arrayContaining(['id', 'user_id', 'token', 'expires_at'])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('npcs table', () => {
|
||||
it('has required columns', () => {
|
||||
const config = getTableConfig(npcs);
|
||||
const columnNames = config.columns.map((c) => c.name);
|
||||
expect(columnNames).toEqual(
|
||||
expect.arrayContaining([
|
||||
'id',
|
||||
'user_id',
|
||||
'name',
|
||||
'race',
|
||||
'role',
|
||||
'level',
|
||||
'tone',
|
||||
'content',
|
||||
'created_at',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('has cascade foreign key to users', () => {
|
||||
const config = getTableConfig(npcs);
|
||||
const fk = config.foreignKeys.find((fk) =>
|
||||
fk.reference().columns.some((c) => c.name === 'user_id')
|
||||
);
|
||||
expect(fk).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generationCounters table', () => {
|
||||
it('has required columns', () => {
|
||||
const config = getTableConfig(generationCounters);
|
||||
const columnNames = config.columns.map((c) => c.name);
|
||||
expect(columnNames).toEqual(
|
||||
expect.arrayContaining([
|
||||
'id',
|
||||
'user_id',
|
||||
'hour_window',
|
||||
'count',
|
||||
'model',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('has cascade foreign key to users', () => {
|
||||
const config = getTableConfig(generationCounters);
|
||||
const fk = config.foreignKeys.find((fk) =>
|
||||
fk.reference().columns.some((c) => c.name === 'user_id')
|
||||
);
|
||||
expect(fk).toBeDefined();
|
||||
});
|
||||
|
||||
it('has composite unique index on userId, hourWindow, model', () => {
|
||||
const config = getTableConfig(generationCounters);
|
||||
const idxNames = config.indexes.map((i) => i.config.name);
|
||||
expect(idxNames).toContain('generation_counters_user_window_model_idx');
|
||||
});
|
||||
});
|
||||
|
||||
describe('translations table', () => {
|
||||
it('has required columns', () => {
|
||||
const config = getTableConfig(translations);
|
||||
const columnNames = config.columns.map((c) => c.name);
|
||||
expect(columnNames).toEqual(
|
||||
expect.arrayContaining([
|
||||
'id',
|
||||
'slug',
|
||||
'type',
|
||||
'language',
|
||||
'content',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('has index on slug, type, language', () => {
|
||||
const config = getTableConfig(translations);
|
||||
const idxNames = config.indexes.map((i) => i.config.name);
|
||||
expect(idxNames).toContain('translations_slug_type_language_idx');
|
||||
});
|
||||
});
|
||||
|
||||
describe('notes table', () => {
|
||||
it('has required columns', () => {
|
||||
const config = getTableConfig(notes);
|
||||
const columnNames = config.columns.map((c) => c.name);
|
||||
expect(columnNames).toEqual(
|
||||
expect.arrayContaining([
|
||||
'id',
|
||||
'user_id',
|
||||
'title',
|
||||
'content',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('has cascade foreign key to users', () => {
|
||||
const config = getTableConfig(notes);
|
||||
const fk = config.foreignKeys.find((fk) =>
|
||||
fk.reference().columns.some((c) => c.name === 'user_id')
|
||||
);
|
||||
expect(fk).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initiativeSessions table', () => {
|
||||
it('has required columns', () => {
|
||||
const config = getTableConfig(initiativeSessions);
|
||||
const columnNames = config.columns.map((c) => c.name);
|
||||
expect(columnNames).toEqual(
|
||||
expect.arrayContaining([
|
||||
'id',
|
||||
'user_id',
|
||||
'name',
|
||||
'participants',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('has cascade foreign key to users', () => {
|
||||
const config = getTableConfig(initiativeSessions);
|
||||
const fk = config.foreignKeys.find((fk) =>
|
||||
fk.reference().columns.some((c) => c.name === 'user_id')
|
||||
);
|
||||
expect(fk).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
+3
-12
@@ -1,14 +1,5 @@
|
||||
import "vitest";
|
||||
import { webcrypto } from 'node:crypto';
|
||||
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
value: {
|
||||
getRandomValues: (arr: Uint8Array) => {
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
arr[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
return arr;
|
||||
},
|
||||
subtle: {},
|
||||
randomUUID: () => "00000000-0000-0000-0000-000000000000",
|
||||
},
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
value: webcrypto,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user