perf: memo GridCard, fuse save fns, fix slash tab active color
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m32s
CI / Deploy production (on server) (push) Has been skipped

This commit is contained in:
Antigravity
2026-06-14 14:06:05 +00:00
parent a8785ed4f1
commit a623454347
120 changed files with 12301 additions and 785 deletions

View File

@@ -117,6 +117,23 @@ describe('brainstorm host-pays billing (Story 3.4)', () => {
checkSessionEntitlementOrThrow('host-1', 'guest-9', true, 'brainstorm_expand'),
).rejects.toThrow()
})
it('AC10: host BYOK + guest quota empty still bills host (guest has no quota, host pays)', async () => {
vi.mocked(hasAnyActiveByok).mockResolvedValue(true)
mockActiveSubscription('PRO')
vi.mocked(redis.eval).mockResolvedValue(1)
// Guest's quota is empty (simulated by checking guest's quota returns 0)
vi.mocked(redis.get).mockResolvedValue('0')
await checkSessionEntitlementOrThrow('host-1', 'guest-9', true, 'brainstorm_expand')
// Verify that host's redis key was incremented, not guest's
expect(redis.eval).toHaveBeenCalled()
const keyArg = String(vi.mocked(redis.eval).mock.calls[0]?.[2])
expect(keyArg).toContain('host-1')
expect(keyArg).not.toContain('guest-9')
})
})
describe('QuotaExceededError.toJSON', () => {

View File

@@ -3,7 +3,8 @@ import { applyByokToConfig } from '@/lib/byok';
vi.mock('@/lib/prisma', () => ({
prisma: {
userAPIKey: { findFirst: vi.fn() },
userAPIKey: { findFirst: vi.fn(), update: vi.fn().mockResolvedValue({}) },
subscription: { findUnique: vi.fn() },
},
}));
@@ -13,11 +14,22 @@ vi.mock('@/lib/crypto', () => ({
hashApiKey: vi.fn(() => 'hash'),
}));
vi.mock('@/lib/entitlements', () => ({
getUserInfo: vi.fn(async () => ({ tier: 'PRO', status: 'ACTIVE' })),
}));
import { prisma } from '@/lib/prisma';
describe('applyByokToConfig', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
userId: 'u1',
tier: 'PRO',
status: 'ACTIVE',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(),
} as any);
});
it('injects user API key into config when active row exists', async () => {

View File

@@ -0,0 +1,178 @@
/**
* Test du ChunkIndexingService — dedup, stale deletion, upsert.
* Mocke l'embedding (pas d'appel API), utilise la vraie DB.
*/
import { prisma } from '../../lib/prisma'
import { ChunkIndexingService } from '../../lib/ai/services/chunk-indexing.service'
const testNoteId = 'test-chunk-000001'
function test(name: string, fn: () => Promise<void>) {
return fn()
.then(() => console.log(`${name}`))
.catch((err: any) => {
console.error(`${name}: ${err.message}`)
process.exitCode = 1
})
}
// Mock l'embedding service : vecteur déterministe basé sur le hash du contenu
const originalEmbedText =
require('../../lib/ai/services/embedding.service').embeddingService.embedText
function mockEmbedding() {
const svc = require('../../lib/ai/services/embedding.service').embeddingService
svc.embedText = async (text: string) => {
const crypto = require('crypto')
const hash = crypto.createHash('md5').update(text).digest()
return Array.from({ length: 1536 }, (_, i) => hash[i % 16] / 255)
}
}
function restoreEmbedding() {
const svc = require('../../lib/ai/services/embedding.service').embeddingService
svc.embedText = originalEmbedText
}
async function ensureTestNote() {
await prisma.note.upsert({
where: { id: testNoteId },
create: {
id: testNoteId,
title: 'Test Note for Chunk Indexing',
content: '<p>Test</p>',
},
update: {},
})
}
async function cleanup() {
await prisma.noteEmbeddingChunk.deleteMany({ where: { noteId: testNoteId } })
}
async function removeTestNote() {
await prisma.note.delete({ where: { id: testNoteId } }).catch(() => {})
}
async function main() {
mockEmbedding()
await ensureTestNote()
await cleanup()
console.log('\n=== US-CHUNK-2 : Indexation incrémentale avec dedup ===\n')
const service = new ChunkIndexingService()
const longContent = Array.from({ length: 8 }, (_, i) =>
`Section ${i} de la note de test. `.repeat(60).trim(),
).join('\n\n')
await test('première indexation → tous les fragments sont nouveaux', async () => {
const result = await service.indexNote(testNoteId, 'Note de test', longContent)
if (result.newFragments === 0)
throw new Error(`attendu newFragments > 0, reçu ${result.newFragments}`)
if (result.deleted !== 0)
throw new Error(`attendu deleted=0, reçu ${result.deleted}`)
if (result.skipped !== 0)
throw new Error(`attendu skipped=0, reçu ${result.skipped}`)
console.log(`${result.newFragments} nouveaux, ${result.totalFragments} total`)
})
await test('deuxième indexation (même contenu) → tout skipped, 0 nouveau', async () => {
const result = await service.indexNote(testNoteId, 'Note de test', longContent)
if (result.newFragments !== 0)
throw new Error(`attendu 0 nouveau, reçu ${result.newFragments}`)
if (result.skipped === 0)
throw new Error(`attendu skipped > 0, reçu ${result.skipped}`)
if (result.deleted !== 0)
throw new Error(`attendu deleted=0, reçu ${result.deleted}`)
console.log(`${result.skipped} skip, 0 nouveau ✓`)
})
await test('modification d\'une section → 1 nouveau, reste skip', async () => {
const sections = Array.from({ length: 8 }, (_, i) =>
`Section ${i === 3 ? 'MODIFIÉE' : i} de la note de test. `.repeat(60).trim(),
)
const modified = sections.join('\n\n')
const result = await service.indexNote(testNoteId, 'Note de test', modified)
if (result.newFragments === 0)
throw new Error(`attendu au moins 1 nouveau fragment, reçu ${result.newFragments}`)
if (result.deleted === 0)
throw new Error(`attendu au moins 1 stale supprimé, reçu ${result.deleted}`)
console.log(`${result.newFragments} nouveau(x), ${result.skipped} skip, ${result.deleted} supprimé(s)`)
})
await test('suppression d\'une section → fragments stale nettoyés', async () => {
const sections = Array.from({ length: 8 }, (_, i) =>
`Section ${i} de la note de test. `.repeat(60).trim(),
)
await service.indexNote(testNoteId, 'Note de test', sections.join('\n\n'))
const beforeCount = await prisma.noteEmbeddingChunk.count({
where: { noteId: testNoteId },
})
const shorter = sections.slice(0, 4).join('\n\n')
const result = await service.indexNote(testNoteId, 'Note de test', shorter)
const afterCount = await prisma.noteEmbeddingChunk.count({
where: { noteId: testNoteId },
})
if (afterCount >= beforeCount)
throw new Error(`attendu count < ${beforeCount}, reçu ${afterCount}`)
if (result.deleted === 0)
throw new Error(`attendu deleted > 0, reçu ${result.deleted}`)
console.log(`${beforeCount}${afterCount} fragments (${result.deleted} supprimés)`)
})
await test('note vide → tous les fragments supprimés', async () => {
await service.indexNote(testNoteId, 'Note de test', longContent)
const result = await service.indexNote(testNoteId, '', '')
const count = await prisma.noteEmbeddingChunk.count({
where: { noteId: testNoteId },
})
if (count !== 0) throw new Error(`attendu 0 fragments, reçu ${count}`)
console.log(` → tous les fragments supprimés ✓`)
})
await test('deleteNoteChunks → supprime tout', async () => {
await service.indexNote(testNoteId, 'Note de test', longContent)
await service.deleteNoteChunks(testNoteId)
const count = await prisma.noteEmbeddingChunk.count({
where: { noteId: testNoteId },
})
if (count !== 0) throw new Error(`attendu 0, reçu ${count}`)
console.log(` → 0 fragment restant ✓`)
})
await test('hasChunks → détection correcte', async () => {
const before = await service.hasChunks(testNoteId)
if (before) throw new Error('attendu false avant indexation')
await service.indexNote(testNoteId, 'Note de test', longContent)
const after = await service.hasChunks(testNoteId)
if (!after) throw new Error('attendu true après indexation')
console.log(` → false avant, true après ✓`)
})
await cleanup()
restoreEmbedding()
await prisma.$disconnect()
console.log('\n=== Tests terminés ===')
}
main().catch(console.error)

View File

@@ -0,0 +1,140 @@
import { chunkNoteContent } from '../../lib/text/note-chunking'
function test(name: string, fn: () => void) {
try {
fn()
console.log(`${name}`)
} catch (err: any) {
console.error(`${name}: ${err.message}`)
process.exitCode = 1
}
}
function assert(condition: any, msg: string) {
if (!condition) throw new Error(msg)
}
console.log('\n=== US-CHUNK-1 : Chunking sémantique ===\n')
test('note vide → aucun fragment', () => {
const chunks = chunkNoteContent('note1', '')
assert(chunks.length === 0, `attendu 0, reçu ${chunks.length}`)
})
test('note très courte (< 10 chars) → aucun fragment', () => {
const chunks = chunkNoteContent('note1', 'Hello')
assert(chunks.length === 0, `attendu 0, reçu ${chunks.length}`)
})
test('note courte (< 1000 chars) → 1 seul fragment', () => {
const text = 'Ceci est une note courte. Elle parle de productivité et de gestion du temps.'
const chunks = chunkNoteContent('note1', text)
assert(chunks.length === 1, `attendu 1, reçu ${chunks.length}`)
assert(chunks[0].chunkIndex === 0, 'chunkIndex doit être 0')
assert(chunks[0].content.includes('productivité'), 'le contenu doit être préservé')
assert(chunks[0].charCount === chunks[0].content.length, 'charCount doit correspondre')
})
test('note longue avec plusieurs paragraphes → plusieurs fragments', () => {
const paragraphs: string[] = []
for (let i = 0; i < 10; i++) {
paragraphs.push(`Paragraphe ${i}. `.repeat(60).trim())
}
const text = paragraphs.join('\n\n')
const chunks = chunkNoteContent('note2', text)
assert(chunks.length > 1, `attendu >1, reçu ${chunks.length}`)
assert(chunks.length <= 15, `attendu <=15 fragments, reçu ${chunks.length}`)
for (let i = 0; i < chunks.length; i++) {
assert(chunks[i].chunkIndex === i, `chunkIndex ${i} incorrect`)
}
})
test('fragmentId est stable (déterministe)', () => {
const text = 'Même contenu donne même hash.'
const chunks1 = chunkNoteContent('noteA', text)
const chunks2 = chunkNoteContent('noteA', text)
assert(chunks1[0].fragmentId === chunks2[0].fragmentId, 'les hash doivent être identiques')
})
test('fragmentId diffère entre notes différentes', () => {
const text = 'Même contenu mais note différente.'
const chunks1 = chunkNoteContent('noteA', text)
const chunks2 = chunkNoteContent('noteB', text)
assert(chunks1[0].fragmentId !== chunks2[0].fragmentId, 'les hash doivent différer par noteId')
})
test('paragraphe géant (> 1500 chars) → sous-découpé aux phrases', () => {
const giantPara =
'Ceci est une phrase très longue. '.repeat(100) + 'Dernière phrase du paragraphe géant.'
const chunks = chunkNoteContent('note3', giantPara)
assert(chunks.length > 1, `attendu >1 fragment, reçu ${chunks.length}`)
for (const chunk of chunks) {
assert(
chunk.content.length <= 2000,
`fragment trop long: ${chunk.charCount} chars`,
)
}
})
test('persan (RTL) → chunking correct', () => {
const persianText =
'یادداشت درباره بهره‌وری.\n\nاین یک پاراگراف فارسی است. این متن برای تست قالب‌بندی راست‌چین نوشته شده است. یادداشت‌های فارسی باید به درستی پردازش شوند.\n\nپاراگراف سوم. محتوای بیشتری برای اطمینان از صحت پردازش.'
const chunks = chunkNoteContent('note-fa', persianText)
assert(chunks.length >= 1, `attendu >=1, reçu ${chunks.length}`)
assert(chunks[0].content.includes('بهره‌وری'), 'contenu persan préservé')
})
test('contenu plain text → pas de transformation', () => {
const plainText = 'Premier paragraphe.\n\nDeuxième paragraphe.'
const chunks = chunkNoteContent('note4', plainText)
assert(chunks.length >= 1, 'au moins 1 fragment')
assert(chunks[0].content.includes('Premier'), 'contenu préservé')
// Le strippage HTML est fait en amont par prepareNoteTextForEmbedding, pas par le chunker
})
test('paragraphe répété → dedup par fragmentId', () => {
const repeatedPara = 'Paragraphe identique répété volontairement.'
const text = `${repeatedPara}\n\n${repeatedPara}\n\n${repeatedPara}`
const chunks = chunkNoteContent('note5', text)
const uniqueIds = new Set(chunks.map((c) => c.fragmentId))
assert(uniqueIds.size === chunks.length, 'les doublons doivent être supprimés')
})
test('modification d\'un paragraphe → fragmentId change pour ce fragment uniquement', () => {
const paraA = 'Section A. '.repeat(80).trim()
const paraB = 'Section B. '.repeat(80).trim()
const paraC = 'Section C. '.repeat(80).trim()
const original = `${paraA}\n\n${paraB}\n\n${paraC}`
const modified = `${paraA} MODIFIE.\n\n${paraB}\n\n${paraC}`
const chunksOriginal = chunkNoteContent('note6', original)
const chunksModified = chunkNoteContent('note6', modified)
assert(chunksOriginal.length >= 2, `original devrait avoir >=2 fragments, reçu ${chunksOriginal.length}`)
const originalIds = new Set(chunksOriginal.map((c) => c.fragmentId))
const newIds = chunksModified.map((c) => c.fragmentId)
const unchanged = newIds.filter((id) => originalIds.has(id))
assert(unchanged.length >= 1, `au moins 1 fragment inchangé attendu, reçu ${unchanged.length} sur ${newIds.length}`)
assert(unchanged.length < newIds.length, `au moins 1 fragment modifié attendu`)
})
test('overlap entre fragments consécutifs', () => {
const paragraphs: string[] = []
for (let i = 0; i < 8; i++) {
paragraphs.push(`Section ${i}. `.repeat(80).trim())
}
const text = paragraphs.join('\n\n')
const chunks = chunkNoteContent('note7', text)
if (chunks.length >= 2) {
const tail = chunks[0].content.slice(-200)
assert(
chunks[1].content.startsWith(tail.slice(0, 50)) || chunks[1].content.includes(tail.slice(0, 30)),
'l\'overlap devrait être présent entre fragments consécutifs',
)
}
})
console.log('\n=== Tests terminés ===')

View File

@@ -72,8 +72,8 @@ describe('entitlements', () => {
const result = await canUseFeature('user1', 'semantic_search');
expect(result.allowed).toBe(true);
expect(result.limit).toBe(100);
expect(result.remaining).toBe(50);
expect(result.limit).toBe(200);
expect(result.remaining).toBe(150);
});
it('should allow ENTERPRISE user unlimited features', async () => {
@@ -130,24 +130,13 @@ describe('entitlements', () => {
expect(result.limit).toBe(30);
});
it('should allow BASIC user to use chat when under limit (10)', async () => {
it('should deny BASIC user to use chat by default (strict lock)', async () => {
mockActiveSubscription('BASIC');
vi.mocked(redis.get).mockResolvedValue('5');
const result = await canUseFeature('user1', 'chat');
expect(result.allowed).toBe(true);
expect(result.limit).toBe(10);
});
it('should deny BASIC user when chat quota is exhausted', async () => {
mockActiveSubscription('BASIC');
vi.mocked(redis.get).mockResolvedValue('10');
const result = await canUseFeature('user1', 'chat');
expect(result.allowed).toBe(false);
expect(result.reason).toBe('QUOTA_EXCEEDED');
expect(result.reason).toBe('FEATURE_NOT_AVAILABLE');
});
it('should fail-open when Redis is down', async () => {
@@ -209,7 +198,7 @@ describe('entitlements', () => {
const result = await canUseFeature('user1', 'semantic_search');
expect(result.limit).toBe(100);
expect(result.limit).toBe(200);
});
it('should drop to BASIC for PAST_DUE past billing period', async () => {