import { afterAll, afterEach, beforeAll, describe, expect, it, vi, } from 'vitest' import { AppBskyDraftCreateDraft, AppBskyDraftDefs, AppBskyDraftGetDrafts, AtpAgent, ids, } from '@atproto/api' import { TID } from '@atproto/common' import { SeedClient, TestNetwork, basicSeed } from '@atproto/dev-env' import { paginateAll } from '../_util.js' type Database = TestNetwork['bsky']['db'] const LIMIT = 10 describe('appview drafts views', () => { let network: TestNetwork let agent: AtpAgent let sc: SeedClient let db: Database // account dids, for convenience let alice: string let bob: string beforeAll(async () => { network = await TestNetwork.create({ dbPostgresSchema: 'bsky_views_drafts', bsky: { draftsLimit: LIMIT, }, }) db = network.bsky.db agent = network.bsky.getAgent() sc = network.getSeedClient() await basicSeed(sc) await network.processAll() alice = sc.dids.alice bob = sc.dids.bob }) afterEach(async () => { vi.resetAllMocks() await clearDrafts(db) }) afterAll(async () => { await network.close() }) const makeDraft = (): AppBskyDraftDefs.Draft => ({ posts: [{ text: 'Hello, world!' }], }) const get = async (actor: string, limit?: number, cursor?: string) => agent.app.bsky.draft.getDrafts( { limit, cursor }, { headers: await network.serviceHeaders(actor, ids.AppBskyDraftGetDrafts), }, ) const create = async (actor: string, draft: AppBskyDraftDefs.Draft) => agent.app.bsky.draft.createDraft( { draft }, { headers: await network.serviceHeaders( actor, ids.AppBskyDraftCreateDraft, ), }, ) const update = async ( actor: string, draftWithId: AppBskyDraftDefs.DraftWithId, ) => agent.app.bsky.draft.updateDraft( { draft: draftWithId }, { headers: await network.serviceHeaders( actor, ids.AppBskyDraftUpdateDraft, ), }, ) const del = async (actor: string, id: string) => agent.app.bsky.draft.deleteDraft( { id }, { headers: await network.serviceHeaders( actor, ids.AppBskyDraftDeleteDraft, ), }, ) describe('creation', () => { it('creates drafts', async () => { const res1 = await create(alice, makeDraft()) const res2 = await create(alice, makeDraft()) const res3 = await create(alice, makeDraft()) expect(res1.data.id).toBeDefined() expect(res2.data.id).toBeDefined() expect(res3.data.id).toBeDefined() expect(new Set([res1.data.id, res2.data.id, res3.data.id]).size).toBe(3) await create(bob, makeDraft()) await create(bob, makeDraft()) const { data: dataAlice } = await get(alice) expect(dataAlice.drafts).toHaveLength(3) const { data: dataBob } = await get(bob) expect(dataBob.drafts).toHaveLength(2) }) it('creates drafts with multiple posts (threads)', async () => { const draft: AppBskyDraftDefs.Draft = { posts: [ { text: 'First post in thread' }, { text: 'Second post in thread' }, { text: 'Third post in thread' }, ], } await create(alice, draft) const { data } = await get(alice) expect(data.drafts).toHaveLength(1) expect(data.drafts[0].draft.posts).toHaveLength(3) expect(data.drafts[0].draft.posts[0].text).toBe('First post in thread') expect(data.drafts[0].draft.posts[2].text).toBe('Third post in thread') }) it('limits the drafts', async () => { // Consume the limit. for (let i = 0; i < LIMIT; i++) { await create(alice, makeDraft()) await network.processAll() } // Try to go over the limit. await expect(create(alice, makeDraft())).rejects.toThrow( AppBskyDraftCreateDraft.DraftLimitReachedError, ) }) }) describe('update', () => { it('updates an existing draft', async () => { const draft1: AppBskyDraftDefs.Draft = { posts: [{ text: 'First version' }], } await create(alice, draft1) const { data: data0 } = await get(alice) expect(data0.drafts).toHaveLength(1) expect(data0.drafts[0].draft.posts[0].text).toBe('First version') const draftId = data0.drafts[0].id const draft2: AppBskyDraftDefs.DraftWithId = { id: draftId, draft: { posts: [{ text: 'Updated version' }] }, } await update(alice, draft2) const { data: data1 } = await get(alice) expect(data1.drafts).toHaveLength(1) expect(data1.drafts[0].draft.posts[0].text).toBe('Updated version') }) it('silently ignores updates to non-existing drafts', async () => { const nonExistingDraft: AppBskyDraftDefs.DraftWithId = { id: TID.nextStr(), draft: { posts: [{ text: 'This draft does not exist' }] }, } await update(alice, nonExistingDraft) const { data } = await get(alice) expect(data.drafts).toHaveLength(0) }) }) describe('deletion', () => { it('removes drafts', async () => { await create(alice, makeDraft()) await create(alice, makeDraft()) await create(alice, makeDraft()) const { data: dataBefore } = await get(alice) expect(dataBefore.drafts).toHaveLength(3) const draft1Id = dataBefore.drafts[0].id const draft2Id = dataBefore.drafts[1].id const draft3Id = dataBefore.drafts[2].id await del(alice, draft1Id) await del(alice, draft3Id) const { data: dataAfter } = await get(alice) expect(dataAfter.drafts).toHaveLength(1) expect(dataAfter.drafts[0].id).toBe(draft2Id) }) it('is idempotent', async () => { await create(alice, makeDraft()) const { data: data0 } = await get(alice) expect(data0.drafts).toHaveLength(1) const draftId = data0.drafts[0].id await del(alice, draftId) const { data: data1 } = await get(alice) expect(data1.drafts).toHaveLength(0) await del(alice, draftId) const { data: data2 } = await get(alice) expect(data2.drafts).toHaveLength(0) }) }) describe('listing', () => { it('gets empty drafts', async () => { const { data } = await get(alice) expect(data.drafts).toHaveLength(0) }) it('drafts are private to each user', async () => { await create(alice, makeDraft()) await create(alice, makeDraft()) await create(bob, makeDraft()) const { data: dataAlice } = await get(alice) expect(dataAlice.drafts).toHaveLength(2) const { data: dataBob } = await get(bob) expect(dataBob.drafts).toHaveLength(1) }) it('includes timestamps', async () => { const beforeCreate = new Date() await create(alice, makeDraft()) const afterCreate = new Date() const { data } = await get(alice) expect(data.drafts).toHaveLength(1) const createdAt = new Date(data.drafts[0].createdAt) expect(createdAt.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime()) expect(createdAt.getTime()).toBeLessThanOrEqual(afterCreate.getTime()) const updatedAt = new Date(data.drafts[0].updatedAt) expect(updatedAt.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime()) expect(updatedAt.getTime()).toBeLessThanOrEqual(afterCreate.getTime()) }) it('paginates drafts in descending order', async () => { for (let i = 0; i < 7; i++) { await create(alice, makeDraft()) } const results = (out: AppBskyDraftGetDrafts.OutputSchema[]) => out.flatMap((res) => res.drafts) const paginator = async (cursor?: string) => { const res = await get(alice, 2, cursor) return res.data } const fullRes = await get(alice) expect(fullRes.data.drafts.length).toBe(7) const paginatedRes = await paginateAll(paginator) paginatedRes.forEach((res) => expect(res.drafts.length).toBeLessThanOrEqual(2), ) const full = results([fullRes.data]) const paginated = results(paginatedRes) // Check items are the same. const sort = ( a: AppBskyDraftDefs.DraftView, b: AppBskyDraftDefs.DraftView, ) => (a.id > b.id ? 1 : -1) expect([...paginated].sort(sort)).toEqual([...full].sort(sort)) // Check pagination ordering (most recent first). expect(paginated.at(0)?.id).toBe(full.at(0)?.id) expect(paginated.at(-1)?.id).toBe(full.at(-1)?.id) }) }) }) const clearDrafts = async (db: Database) => { await db.db.deleteFrom('draft').execute() }