mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-06 14:01:30 +08:00
197 lines
6.9 KiB
TypeScript
197 lines
6.9 KiB
TypeScript
import { existsSync } from 'node:fs'
|
|
|
|
import { application } from '@application'
|
|
import { messageTable } from '@data/db/schemas/message'
|
|
import { topicTable } from '@data/db/schemas/topic'
|
|
import type { MessageData } from '@shared/data/types/message'
|
|
import { eq } from 'drizzle-orm'
|
|
import { afterAll, describe, expect, it } from 'vitest'
|
|
|
|
import { withRoot } from '../messageTree'
|
|
import { truncateAll } from '../internal/truncate'
|
|
import { setupTestDatabase } from '../testDatabase'
|
|
|
|
function mainText(content: string): MessageData {
|
|
return { parts: [{ type: 'text', text: content }] }
|
|
}
|
|
|
|
describe('setupTestDatabase — basic lifecycle and schema', () => {
|
|
const dbh = setupTestDatabase()
|
|
|
|
it('PRAGMA foreign_keys is ON after init', () => {
|
|
const result = dbh.sqlite.pragma('foreign_keys', { simple: true })
|
|
expect(Number(result)).toBe(1)
|
|
})
|
|
|
|
it('PRAGMA integrity_check returns ok', () => {
|
|
const result = dbh.sqlite.pragma('integrity_check', { simple: true })
|
|
expect(result).toBe('ok')
|
|
})
|
|
|
|
it('topic table exists and is empty', async () => {
|
|
const rows = await dbh.db.select().from(topicTable)
|
|
expect(rows).toEqual([])
|
|
})
|
|
|
|
it('__drizzle_migrations journal table is preserved across init', () => {
|
|
const result = dbh.sqlite.prepare("SELECT name FROM sqlite_master WHERE name='__drizzle_migrations'").all()
|
|
expect(result).toHaveLength(1)
|
|
})
|
|
|
|
it('FTS5 virtual table message_fts is created by CUSTOM_SQL_STATEMENTS', () => {
|
|
const result = dbh.sqlite.prepare("SELECT name FROM sqlite_master WHERE name='message_fts'").all()
|
|
expect(result).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('setupTestDatabase — data isolation between tests', () => {
|
|
const dbh = setupTestDatabase()
|
|
|
|
it('test A inserts one topic row', async () => {
|
|
await dbh.db.insert(topicTable).values({ id: 'topic-iso-a', orderKey: 'a0', createdAt: 1, updatedAt: 1 })
|
|
const rows = await dbh.db.select().from(topicTable)
|
|
expect(rows).toHaveLength(1)
|
|
})
|
|
|
|
it('test B starts clean — previous test data is truncated', async () => {
|
|
const rows = await dbh.db.select().from(topicTable)
|
|
expect(rows).toEqual([])
|
|
})
|
|
|
|
it('__drizzle_migrations journal remains populated between tests (not truncated)', () => {
|
|
const result = dbh.sqlite.prepare('SELECT COUNT(*) AS cnt FROM __drizzle_migrations').get() as { cnt: number }
|
|
expect(result.cnt).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
describe('setupTestDatabase — transaction', () => {
|
|
const dbh = setupTestDatabase()
|
|
|
|
it('data inserted inside a transaction is visible after commit', async () => {
|
|
dbh.db.transaction((tx) => {
|
|
tx.insert(topicTable).values({ id: 'topic-tx', orderKey: 'a0', createdAt: 1, updatedAt: 1 }).run()
|
|
})
|
|
const rows = await dbh.db.select().from(topicTable).where(eq(topicTable.id, 'topic-tx'))
|
|
expect(rows).toHaveLength(1)
|
|
})
|
|
|
|
it('FK enforcement stays ON after a transaction', () => {
|
|
dbh.db.transaction((tx) => {
|
|
tx.insert(topicTable).values({ id: 'topic-fk', orderKey: 'a0', createdAt: 1, updatedAt: 1 }).run()
|
|
})
|
|
const result = dbh.sqlite.pragma('foreign_keys', { simple: true })
|
|
expect(Number(result)).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe('setupTestDatabase — FTS5 triggers and truncate cascade', () => {
|
|
const dbh = setupTestDatabase()
|
|
|
|
async function seedTopic(id: string) {
|
|
await dbh.db.insert(topicTable).values({ id, orderKey: 'a0', createdAt: 1, updatedAt: 1 })
|
|
}
|
|
|
|
it('INSERT INTO message populates message_fts via AFTER INSERT trigger', async () => {
|
|
await seedTopic('topic-fts-1')
|
|
await dbh.db.insert(messageTable).values(
|
|
withRoot('topic-fts-1', [
|
|
{
|
|
id: 'msg-fts-1',
|
|
parentId: null,
|
|
topicId: 'topic-fts-1',
|
|
role: 'user',
|
|
data: mainText('hello world'),
|
|
status: 'success',
|
|
siblingsGroupId: 0,
|
|
createdAt: 1,
|
|
updatedAt: 1
|
|
}
|
|
])
|
|
)
|
|
|
|
const result = dbh.sqlite.prepare('SELECT rowid FROM message_fts WHERE message_fts MATCH ?').all('hello')
|
|
expect(result.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('truncateAll clears message_fts via the AFTER DELETE trigger cascade', async () => {
|
|
await seedTopic('topic-fts-2')
|
|
await dbh.db.insert(messageTable).values(
|
|
withRoot('topic-fts-2', [
|
|
{
|
|
id: 'msg-fts-2',
|
|
parentId: null,
|
|
topicId: 'topic-fts-2',
|
|
role: 'user',
|
|
data: mainText('goodbye'),
|
|
status: 'success',
|
|
siblingsGroupId: 0,
|
|
createdAt: 1,
|
|
updatedAt: 1
|
|
}
|
|
])
|
|
)
|
|
truncateAll(dbh.db, dbh.sqlite)
|
|
const count = dbh.sqlite.prepare('SELECT COUNT(*) AS n FROM message_fts').get() as { n: number }
|
|
expect(count.n).toBe(0)
|
|
})
|
|
|
|
it('truncateAll does not throw when message has no extractable text', async () => {
|
|
await seedTopic('topic-null-fts')
|
|
// No extractable text — the FTS trigger COALESCEs the missing concat to ''.
|
|
await dbh.db.insert(messageTable).values(
|
|
withRoot('topic-null-fts', [
|
|
{
|
|
id: 'msg-null-fts',
|
|
parentId: null,
|
|
topicId: 'topic-null-fts',
|
|
role: 'user',
|
|
data: { parts: [] },
|
|
status: 'success',
|
|
siblingsGroupId: 0,
|
|
createdAt: 1,
|
|
updatedAt: 1
|
|
}
|
|
])
|
|
)
|
|
expect(() => truncateAll(dbh.db, dbh.sqlite)).not.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('setupTestDatabase — production code routing via MockMainDbService', () => {
|
|
const dbh = setupTestDatabase()
|
|
|
|
it('application.get("DbService").getDb() returns the same DB instance', async () => {
|
|
const fromApp = application.get('DbService').getDb()
|
|
await dbh.db.insert(topicTable).values({ id: 'topic-routing', orderKey: 'a0', createdAt: 1, updatedAt: 1 })
|
|
|
|
// Read using the DB instance obtained via the production access pattern.
|
|
const rows = await fromApp.select().from(topicTable).where(eq(topicTable.id, 'topic-routing'))
|
|
expect(rows).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
// This last test runs after all describes; it simply observes that the
|
|
// harness does not obviously leak tmpdirs. It cannot be exhaustive within
|
|
// a single test file (we only see our own tmpdirs), so we just smoke-test
|
|
// that no cs-test-db-* directory appears under a non-existent path.
|
|
describe('setupTestDatabase — cleanup smoke check', () => {
|
|
const dbh = setupTestDatabase()
|
|
|
|
let dbPathAtSetup: string | null = null
|
|
it('records the db path during setup', () => {
|
|
// Our harness does not expose the db `path`, so we skip a strict path check
|
|
// and just assert the raw connection handle is available now.
|
|
dbPathAtSetup = '<recorded>'
|
|
expect(dbh.sqlite).toBeDefined()
|
|
})
|
|
|
|
afterAll(() => {
|
|
// Minimal smoke test — real leak detection is done by the full suite +
|
|
// `find /tmp -name 'cs-test-db-*'` step in the plan's verification
|
|
// section. Here we just ensure the recording completed.
|
|
expect(dbPathAtSetup).toBe('<recorded>')
|
|
// Reassure linter that `existsSync` is still imported for future use.
|
|
void existsSync
|
|
})
|
|
})
|