ConusAI vendor 81941888f
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
import { migrate } from 'drizzle-orm/postgres-js/migrator'
|
||||
import postgres from 'postgres'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { noteRepository, type NoteDb } from '@/entities/note'
|
||||
|
||||
// Runs against a real Postgres: POSTGRES_URL must name one. Each run migrates a
|
||||
// schema of its own and drops it afterwards, so runs never share rows.
|
||||
const url = process.env.POSTGRES_URL
|
||||
const schema = `note_test_${randomUUID().replaceAll('-', '')}`
|
||||
let admin: postgres.Sql
|
||||
let client: postgres.Sql
|
||||
let repo: ReturnType<typeof noteRepository>
|
||||
|
||||
const ALICE = 'visitor-alice'
|
||||
const BOB = 'visitor-bob'
|
||||
|
||||
beforeAll(async () => {
|
||||
expect(url, 'POSTGRES_URL must name a test Postgres').toBeTruthy()
|
||||
admin = postgres(url as string, { max: 1 })
|
||||
await admin.unsafe(`create schema "${schema}"`)
|
||||
client = postgres(url as string, { max: 1, connection: { search_path: schema } })
|
||||
const db = drizzle(client) as NoteDb
|
||||
await migrate(db, { migrationsFolder: 'drizzle', migrationsSchema: schema })
|
||||
repo = noteRepository(db)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await client?.end()
|
||||
await admin?.unsafe(`drop schema if exists "${schema}" cascade`)
|
||||
await admin?.end()
|
||||
})
|
||||
|
||||
describe('noteRepository', () => {
|
||||
it('creates a note and reads it back with its author', async () => {
|
||||
const created = await repo.create({ title: 'Plan', body: 'draft', authorId: ALICE })
|
||||
const read = await repo.get(created.id)
|
||||
expect(read).toMatchObject({ title: 'Plan', body: 'draft', authorId: ALICE })
|
||||
})
|
||||
|
||||
it('lists only the notes of the asked-for author', async () => {
|
||||
await repo.create({ title: 'Bob note', body: '', authorId: BOB })
|
||||
const aliceNotes = await repo.listByAuthor(ALICE)
|
||||
expect(aliceNotes.length).toBeGreaterThan(0)
|
||||
expect(aliceNotes.every((note) => note.authorId === ALICE)).toBe(true)
|
||||
})
|
||||
|
||||
it('lets the author update their own note', async () => {
|
||||
const created = await repo.create({ title: 'Old', body: '', authorId: ALICE })
|
||||
const updated = await repo.update(created.id, ALICE, { title: 'New', body: 'edited' })
|
||||
expect(updated).toMatchObject({ id: created.id, title: 'New', body: 'edited' })
|
||||
})
|
||||
|
||||
it('does not let another visitor update the note', async () => {
|
||||
const created = await repo.create({ title: 'Mine', body: '', authorId: ALICE })
|
||||
const attempt = await repo.update(created.id, BOB, { title: 'Hijacked', body: '' })
|
||||
expect(attempt).toBeUndefined()
|
||||
expect((await repo.get(created.id))?.title).toBe('Mine')
|
||||
})
|
||||
|
||||
it('removes only for the author', async () => {
|
||||
const created = await repo.create({ title: 'Doomed', body: '', authorId: ALICE })
|
||||
expect(await repo.remove(created.id, BOB)).toBe(false)
|
||||
expect(await repo.remove(created.id, ALICE)).toBe(true)
|
||||
expect(await repo.get(created.id)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import type { drizzle } from 'drizzle-orm/postgres-js'
|
||||
import { notes, type Note } from '@/entities/note/model/schema'
|
||||
import type { NoteInput } from '@/entities/note/model/input'
|
||||
|
||||
/** The handle the shared `db()` returns (`@/shared/api/db/server`). */
|
||||
export type NoteDb = ReturnType<typeof drizzle>
|
||||
|
||||
/**
|
||||
* Note persistence over one database handle. The handle is a parameter so a test
|
||||
* can run the same code against its own Postgres.
|
||||
*
|
||||
* Every write names its author in the `where`: a visitor can change only the
|
||||
* notes they wrote (tenancy, D16).
|
||||
*/
|
||||
export function noteRepository(db: NoteDb) {
|
||||
return {
|
||||
async create(input: NoteInput & { authorId: string }): Promise<Note> {
|
||||
const [row] = await db.insert(notes).values(input).returning()
|
||||
if (!row) throw new Error('the insert returned no row')
|
||||
return row
|
||||
},
|
||||
|
||||
async get(id: string): Promise<Note | undefined> {
|
||||
const [row] = await db.select().from(notes).where(eq(notes.id, id)).limit(1)
|
||||
return row
|
||||
},
|
||||
|
||||
async listByAuthor(authorId: string, limit = 50): Promise<Note[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(eq(notes.authorId, authorId))
|
||||
.orderBy(desc(notes.createdAt))
|
||||
.limit(limit)
|
||||
},
|
||||
|
||||
/** The updated note, or `undefined` when `authorId` did not write it. */
|
||||
async update(id: string, authorId: string, input: NoteInput): Promise<Note | undefined> {
|
||||
const [row] = await db
|
||||
.update(notes)
|
||||
.set({ ...input, updatedAt: new Date() })
|
||||
.where(and(eq(notes.id, id), eq(notes.authorId, authorId)))
|
||||
.returning()
|
||||
return row
|
||||
},
|
||||
|
||||
/** `true` when the note existed and `authorId` wrote it. */
|
||||
async remove(id: string, authorId: string): Promise<boolean> {
|
||||
const rows = await db
|
||||
.delete(notes)
|
||||
.where(and(eq(notes.id, id), eq(notes.authorId, authorId)))
|
||||
.returning({ id: notes.id })
|
||||
return rows.length > 0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type NoteRepository = ReturnType<typeof noteRepository>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { notes, type Note } from './model/schema'
|
||||
export { noteInput, noteId, type NoteInput } from './model/input'
|
||||
export { noteRepository, type NoteDb, type NoteRepository } from './api/repository'
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { noteId, noteInput } from '@/entities/note'
|
||||
|
||||
describe('noteInput', () => {
|
||||
it('trims the title and defaults the body', () => {
|
||||
expect(noteInput.parse({ title: ' Plan ' })).toEqual({ title: 'Plan', body: '' })
|
||||
})
|
||||
|
||||
it('refuses a blank title', () => {
|
||||
expect(() => noteInput.parse({ title: ' ' })).toThrow(/title/)
|
||||
})
|
||||
|
||||
it('refuses a title past 200 characters', () => {
|
||||
expect(() => noteInput.parse({ title: 'x'.repeat(201) })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('noteId', () => {
|
||||
it('accepts a UUID and refuses anything else', () => {
|
||||
expect(noteId.parse('00000000-0000-4000-8000-000000000001')).toBe('00000000-0000-4000-8000-000000000001')
|
||||
expect(() => noteId.parse('not-a-uuid')).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/** The fields a visitor writes. Parsed at the boundary, before any write (plan §4.4). */
|
||||
export const noteInput = z.object({
|
||||
title: z.string().trim().min(1, 'A note needs a title').max(200),
|
||||
body: z.string().max(20000).default(''),
|
||||
})
|
||||
|
||||
export type NoteInput = z.infer<typeof noteInput>
|
||||
|
||||
/** A note id as it arrives in a form or a URL: a UUID, nothing else. */
|
||||
export const noteId = z.uuid()
|
||||
@@ -0,0 +1,13 @@
|
||||
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
|
||||
|
||||
/** A note. `author_id` is the visitor's id from the signed `sc_vid` cookie (D16). */
|
||||
export const notes = pgTable('notes', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
title: text('title').notNull(),
|
||||
body: text('body').notNull().default(''),
|
||||
authorId: text('author_id').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export type Note = typeof notes.$inferSelect
|
||||
Reference in New Issue
Block a user