feat(projects): list + create API and creatable ProjectAutocomplete (T4)
- GET /api/projects returns the shared global list; POST /api/projects creates a project, deduped case-insensitively (F3). - Repository find-or-create trims and matches on lower(name); a unique index on lower(name) enforces the same rule at the DB (replacing the case- sensitive column unique constraint) — migration 0001. - ProjectAutocomplete: creatable autocomplete that lists projects, offers to create an unknown name inline, and selects it without reload. - Integration test (dedupe/list against Postgres) + component test.
This commit is contained in:
148
app/components/ProjectAutocomplete.vue
Normal file
148
app/components/ProjectAutocomplete.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
// Creatable autocomplete over the shared global project list (C4, F3): pick an
|
||||
// existing project or persist a new one inline, deduped case-insensitively.
|
||||
interface Project {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const props = defineProps<{ modelValue?: Project | null }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [Project | null] }>()
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const query = ref(props.modelValue?.name ?? '')
|
||||
const open = ref(false)
|
||||
const busy = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
projects.value = await $fetch<Project[]>('/api/projects')
|
||||
})
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
if (!q) return projects.value
|
||||
return projects.value.filter((p) => p.name.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
// Case-insensitive: typing "acme" when "Acme" exists is not a new project.
|
||||
const exactMatch = computed(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
return projects.value.find((p) => p.name.toLowerCase() === q)
|
||||
})
|
||||
|
||||
const canCreate = computed(() => query.value.trim().length > 0 && !exactMatch.value)
|
||||
|
||||
function select(project: Project) {
|
||||
query.value = project.name
|
||||
open.value = false
|
||||
emit('update:modelValue', project)
|
||||
}
|
||||
|
||||
async function create() {
|
||||
const name = query.value.trim()
|
||||
if (!name || busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
const project = await $fetch<Project>('/api/projects', {
|
||||
method: 'POST',
|
||||
body: { name },
|
||||
})
|
||||
if (!projects.value.some((p) => p.id === project.id)) {
|
||||
projects.value.push(project)
|
||||
}
|
||||
select(project)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="project-autocomplete">
|
||||
<input
|
||||
v-model="query"
|
||||
type="text"
|
||||
class="input"
|
||||
data-test="project-input"
|
||||
placeholder="Project…"
|
||||
autocomplete="off"
|
||||
@focus="open = true"
|
||||
@input="open = true"
|
||||
>
|
||||
<ul v-if="open && (filtered.length || canCreate)" class="options">
|
||||
<li v-for="project in filtered" :key="project.id">
|
||||
<button
|
||||
type="button"
|
||||
class="option"
|
||||
data-test="project-option"
|
||||
:data-project-id="project.id"
|
||||
@click="select(project)"
|
||||
>
|
||||
{{ project.name }}
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="canCreate">
|
||||
<button
|
||||
type="button"
|
||||
class="option option--create"
|
||||
data-test="project-create"
|
||||
:disabled="busy"
|
||||
@click="create"
|
||||
>
|
||||
+ Create “{{ query.trim() }}”
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.project-autocomplete {
|
||||
position: relative;
|
||||
font-family: 'Cutive Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
border: 1px dashed currentColor;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
padding: 0.4rem 0.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.options {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0.25rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border: 1px solid currentColor;
|
||||
background: var(--surface, #fff);
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 0.4rem 0.5rem;
|
||||
}
|
||||
|
||||
.option:hover,
|
||||
.option:focus {
|
||||
outline: 1px solid currentColor;
|
||||
}
|
||||
|
||||
.option--create {
|
||||
opacity: 0.85;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
7
server/api/projects.get.ts
Normal file
7
server/api/projects.get.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { getDb } from '../db/client'
|
||||
import { listProjects } from '../db/repositories/projects'
|
||||
|
||||
// The shared, global project list (F3).
|
||||
export default defineEventHandler(async () => {
|
||||
return listProjects(getDb())
|
||||
})
|
||||
12
server/api/projects.post.ts
Normal file
12
server/api/projects.post.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getDb } from '../db/client'
|
||||
import { createProject } from '../db/repositories/projects'
|
||||
|
||||
// Create a project (case-insensitively deduped) and return it (F3).
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<{ name?: unknown }>(event)
|
||||
const name = typeof body?.name === 'string' ? body.name.trim() : ''
|
||||
if (!name) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Project name is required' })
|
||||
}
|
||||
return createProject(getDb(), name)
|
||||
})
|
||||
2
server/db/migrations/0001_boring_randall.sql
Normal file
2
server/db/migrations/0001_boring_randall.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "projects" DROP CONSTRAINT "projects_name_unique";--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "projects_name_lower_idx" ON "projects" USING btree (lower("name"));
|
||||
189
server/db/migrations/meta/0001_snapshot.json
Normal file
189
server/db/migrations/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,189 @@
|
||||
{
|
||||
"id": "16079f27-2c98-41cc-a954-d09599102874",
|
||||
"prevId": "a68da650-95df-4918-9def-4cccec67a7bf",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.defects": {
|
||||
"name": "defects",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"section_id": {
|
||||
"name": "section_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"verbatim": {
|
||||
"name": "verbatim",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"reporter_email": {
|
||||
"name": "reporter_email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"defects_project_id_projects_id_fk": {
|
||||
"name": "defects_project_id_projects_id_fk",
|
||||
"tableFrom": "defects",
|
||||
"tableTo": "projects",
|
||||
"columnsFrom": [
|
||||
"project_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.projects": {
|
||||
"name": "projects",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"projects_name_lower_idx": {
|
||||
"name": "projects_name_lower_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "lower(\"name\")",
|
||||
"asc": true,
|
||||
"isExpression": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.push_subscriptions": {
|
||||
"name": "push_subscriptions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"endpoint": {
|
||||
"name": "endpoint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"p256dh": {
|
||||
"name": "p256dh",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"auth": {
|
||||
"name": "auth",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"reporter_email": {
|
||||
"name": "reporter_email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"push_subscriptions_endpoint_unique": {
|
||||
"name": "push_subscriptions_endpoint_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"endpoint"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1779913917235,
|
||||
"tag": "0000_loving_black_bird",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1779916898127,
|
||||
"tag": "0001_boring_randall",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
44
server/db/repositories/projects.ts
Normal file
44
server/db/repositories/projects.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { asc, sql } from 'drizzle-orm'
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||
import type * as schema from '../schema'
|
||||
import { projects } from '../schema'
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>
|
||||
|
||||
export type Project = typeof projects.$inferSelect
|
||||
|
||||
/** The shared, global project list, alphabetical by name (F3). */
|
||||
export async function listProjects(db: Db): Promise<Project[]> {
|
||||
return db.select().from(projects).orderBy(asc(projects.name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Find-or-create a project by name, deduping case-insensitively so "Acme" and
|
||||
* "acme" resolve to one project (F3). The DB enforces the same rule via a
|
||||
* unique index on `lower(name)`; the catch handles a concurrent insert race.
|
||||
*/
|
||||
export async function createProject(db: Db, rawName: string): Promise<Project> {
|
||||
const name = rawName.trim()
|
||||
if (!name) throw new Error('Project name is required')
|
||||
|
||||
const existing = await findByName(db, name)
|
||||
if (existing) return existing
|
||||
|
||||
try {
|
||||
const [row] = await db.insert(projects).values({ name }).returning()
|
||||
return row!
|
||||
} catch (err) {
|
||||
const row = await findByName(db, name)
|
||||
if (row) return row
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function findByName(db: Db, name: string): Promise<Project | undefined> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(sql`lower(${projects.name}) = lower(${name})`)
|
||||
.limit(1)
|
||||
return row
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
// Drizzle (Postgres) schema (C8).
|
||||
// `section_id` is a plain string referencing the in-code board section ids
|
||||
// (C1) — no foreign key, because sections live in code, not the DB (ADR 0001).
|
||||
import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core'
|
||||
import { pgTable, uuid, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
export const projects = pgTable('projects', {
|
||||
id: uuid('id').primaryKey().$defaultFn(randomUUID),
|
||||
name: text('name').notNull().unique(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
export const projects = pgTable(
|
||||
'projects',
|
||||
{
|
||||
id: uuid('id').primaryKey().$defaultFn(randomUUID),
|
||||
name: text('name').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
// Case-insensitive uniqueness: "Acme" and "acme" are the same project (F3).
|
||||
(t) => [uniqueIndex('projects_name_lower_idx').on(sql`lower(${t.name})`)],
|
||||
)
|
||||
|
||||
export const defects = pgTable('defects', {
|
||||
id: uuid('id').primaryKey().$defaultFn(randomUUID),
|
||||
|
||||
@@ -11,7 +11,7 @@ review.
|
||||
|
||||
## Phase 2 — File a defect (G1)
|
||||
- [x] **T3** Postgres schema + migrations (C8) (S) — _deps: T1_
|
||||
- [ ] **T4** Projects API + ProjectAutocomplete (C4) — F3 (M) — _deps: T3_
|
||||
- [x] **T4** Projects API + ProjectAutocomplete (C4) — F3 (M) — _deps: T3_
|
||||
- [ ] **T5** File a defect: POST /api/defects + DefectForm (C3) — F2 (M) — _deps: T2, T3, T4_
|
||||
- [ ] ⛳ **Checkpoint:** defect files end-to-end (dev auth), tests pass, human review
|
||||
|
||||
|
||||
93
tests/project-autocomplete.nuxt.spec.ts
Normal file
93
tests/project-autocomplete.nuxt.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment nuxt
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { defineEventHandler, readBody } from 'h3'
|
||||
import ProjectAutocomplete from '../app/components/ProjectAutocomplete.vue'
|
||||
|
||||
interface Row {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
let store: Row[]
|
||||
|
||||
beforeEach(() => {
|
||||
store = [
|
||||
{ id: '1', name: 'Acme' },
|
||||
{ id: '2', name: 'Globex' },
|
||||
]
|
||||
// One endpoint backing both list (GET) and creatable autocomplete (POST),
|
||||
// deduping case-insensitively like the real API.
|
||||
registerEndpoint(
|
||||
'/api/projects',
|
||||
defineEventHandler(async (event) => {
|
||||
if (event.method === 'GET') return store
|
||||
const { name } = await readBody<{ name: string }>(event)
|
||||
const trimmed = name.trim()
|
||||
const existing = store.find(
|
||||
(p) => p.name.toLowerCase() === trimmed.toLowerCase(),
|
||||
)
|
||||
if (existing) return existing
|
||||
const created = { id: String(store.length + 1), name: trimmed }
|
||||
store.push(created)
|
||||
return created
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe('ProjectAutocomplete', () => {
|
||||
it('lists the existing projects when opened', async () => {
|
||||
const wrapper = await mountSuspended(ProjectAutocomplete)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('[data-test="project-input"]').trigger('focus')
|
||||
const names = wrapper
|
||||
.findAll('[data-test="project-option"]')
|
||||
.map((el) => el.text())
|
||||
expect(names).toContain('Acme')
|
||||
expect(names).toContain('Globex')
|
||||
})
|
||||
|
||||
it('offers to create a project that does not exist yet', async () => {
|
||||
const wrapper = await mountSuspended(ProjectAutocomplete)
|
||||
await flushPromises()
|
||||
|
||||
const input = wrapper.find('[data-test="project-input"]')
|
||||
await input.setValue('Initech')
|
||||
expect(wrapper.find('[data-test="project-create"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not offer to create a name that already exists (case-insensitive)', async () => {
|
||||
const wrapper = await mountSuspended(ProjectAutocomplete)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('[data-test="project-input"]').setValue('acme')
|
||||
expect(wrapper.find('[data-test="project-create"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('persists a new project, selects it, and makes it selectable without reload', async () => {
|
||||
const wrapper = await mountSuspended(ProjectAutocomplete)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('[data-test="project-input"]').setValue('Initech')
|
||||
await wrapper.find('[data-test="project-create"]').trigger('click')
|
||||
|
||||
// Selected and emitted to the parent (the POST resolves asynchronously).
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
||||
})
|
||||
await flushPromises()
|
||||
const emitted = wrapper.emitted('update:modelValue')!
|
||||
expect(emitted.at(-1)![0]).toMatchObject({ name: 'Initech' })
|
||||
|
||||
// Persisted to the store and now selectable without reload — reopening the
|
||||
// list (the create flow closes it) shows the new project.
|
||||
expect(store.map((p) => p.name)).toContain('Initech')
|
||||
await wrapper.find('[data-test="project-input"]').trigger('focus')
|
||||
const names = wrapper
|
||||
.findAll('[data-test="project-option"]')
|
||||
.map((el) => el.text())
|
||||
expect(names).toContain('Initech')
|
||||
})
|
||||
})
|
||||
70
tests/projects.spec.ts
Normal file
70
tests/projects.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'
|
||||
import { setupTestDb, truncateAll, type TestDb } from './helpers/db'
|
||||
import { createProject, listProjects } from '../server/db/repositories/projects'
|
||||
|
||||
let db: TestDb
|
||||
let pool: { end: () => Promise<void> }
|
||||
|
||||
beforeAll(async () => {
|
||||
const setup = await setupTestDb()
|
||||
db = setup.db
|
||||
pool = setup.pool
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAll(db)
|
||||
})
|
||||
|
||||
describe('projects repository', () => {
|
||||
it('lists nothing when no projects exist', async () => {
|
||||
expect(await listProjects(db)).toEqual([])
|
||||
})
|
||||
|
||||
it('creates a project and lists it', async () => {
|
||||
const created = await createProject(db, 'Acme')
|
||||
expect(created.id).toBeDefined()
|
||||
expect(created.name).toBe('Acme')
|
||||
|
||||
const list = await listProjects(db)
|
||||
expect(list).toHaveLength(1)
|
||||
expect(list[0]!.name).toBe('Acme')
|
||||
})
|
||||
|
||||
it('dedupes case-insensitively — "Acme" then "acme" yields one project', async () => {
|
||||
const first = await createProject(db, 'Acme')
|
||||
const second = await createProject(db, 'acme')
|
||||
|
||||
expect(second.id).toBe(first.id)
|
||||
expect(second.name).toBe('Acme') // keeps the original casing
|
||||
expect(await listProjects(db)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('trims surrounding whitespace and dedupes against the trimmed name', async () => {
|
||||
const first = await createProject(db, 'Globex')
|
||||
const second = await createProject(db, ' globex ')
|
||||
|
||||
expect(second.id).toBe(first.id)
|
||||
expect(await listProjects(db)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects a blank name', async () => {
|
||||
await expect(createProject(db, ' ')).rejects.toThrow()
|
||||
expect(await listProjects(db)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lists projects alphabetically by name', async () => {
|
||||
await createProject(db, 'Zebra')
|
||||
await createProject(db, 'Acme')
|
||||
await createProject(db, 'Monarch')
|
||||
|
||||
expect((await listProjects(db)).map((p) => p.name)).toEqual([
|
||||
'Acme',
|
||||
'Monarch',
|
||||
'Zebra',
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user