fix: ai audit improvements
This commit is contained in:
@@ -436,4 +436,119 @@ describe('useListsStore', () => {
|
||||
|
||||
expect(store.pendingCount).toBe(0)
|
||||
})
|
||||
|
||||
it('removes user from list on the server when online without adding to sync queue', async () => {
|
||||
listsApiMocks.removeUserFromListApi.mockResolvedValueOnce(undefined)
|
||||
|
||||
const store = useListsStore()
|
||||
await store.removeUserFromList('list-1', 'friend@example.com')
|
||||
|
||||
expect(listsApiMocks.removeUserFromListApi).toHaveBeenCalledWith({
|
||||
list_id: 'list-1',
|
||||
email: 'friend@example.com',
|
||||
})
|
||||
expect(store.pendingCount).toBe(0)
|
||||
})
|
||||
|
||||
it('throws error when removing user from list while offline without calling API', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
|
||||
|
||||
const store = useListsStore()
|
||||
await expect(store.removeUserFromList('list-1', 'friend@example.com')).rejects.toThrow(
|
||||
'Cannot remove user from list while offline',
|
||||
)
|
||||
|
||||
expect(listsApiMocks.removeUserFromListApi).not.toHaveBeenCalled()
|
||||
expect(store.pendingCount).toBe(0)
|
||||
})
|
||||
|
||||
it('propagates error when removing user from list fails on server', async () => {
|
||||
listsApiMocks.removeUserFromListApi.mockRejectedValueOnce(new Error('User not found'))
|
||||
|
||||
const store = useListsStore()
|
||||
await expect(store.removeUserFromList('list-1', 'unknown@example.com')).rejects.toThrow(
|
||||
'User not found',
|
||||
)
|
||||
|
||||
expect(store.pendingCount).toBe(0)
|
||||
})
|
||||
|
||||
it('does not sync before the debounce delay elapses, then syncs once it does', async () => {
|
||||
listsApiMocks.createListItemApi.mockResolvedValueOnce({
|
||||
id: 'server-item-1',
|
||||
list_id: 'list-1',
|
||||
title: 'Milk',
|
||||
is_completed: false,
|
||||
})
|
||||
|
||||
const store = useListsStore()
|
||||
await store.createListItem('list-1', 'Milk')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(399)
|
||||
expect(listsApiMocks.createListItemApi).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(listsApiMocks.createListItemApi).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('collapses a burst of mutations within the debounce window into a single sync pass', async () => {
|
||||
listsApiMocks.createListItemApi
|
||||
.mockResolvedValueOnce({
|
||||
id: 'server-item-a',
|
||||
list_id: 'list-1',
|
||||
title: 'A',
|
||||
is_completed: false,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'server-item-b',
|
||||
list_id: 'list-1',
|
||||
title: 'B',
|
||||
is_completed: false,
|
||||
})
|
||||
|
||||
const store = useListsStore()
|
||||
await store.createListItem('list-1', 'A')
|
||||
await store.createListItem('list-1', 'B')
|
||||
|
||||
expect(listsApiMocks.createListItemApi).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
|
||||
expect(listsApiMocks.createListItemApi).toHaveBeenCalledTimes(2)
|
||||
expect(store.pendingCount).toBe(0)
|
||||
})
|
||||
|
||||
it('serializes pullListItems() behind an in-flight sync() so they never race on the same rows', async () => {
|
||||
let resolveGetLists!: (value: unknown[]) => void
|
||||
listsApiMocks.getListsApi.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveGetLists = resolve
|
||||
}),
|
||||
)
|
||||
listsApiMocks.getListItemsApi.mockResolvedValueOnce([
|
||||
{ id: 'server-item-1', list_id: 'list-1', title: 'Milk', is_completed: false },
|
||||
])
|
||||
|
||||
const store = useListsStore()
|
||||
|
||||
const syncPromise = store.sync()
|
||||
const pullPromise = store.pullListItems('list-1')
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// pullListItems is chained behind sync() via the shared operation queue,
|
||||
// so its own (already-mocked, instantly resolvable) API call must not
|
||||
// fire while sync()'s getListsApi call is still pending.
|
||||
expect(listsApiMocks.getListItemsApi).not.toHaveBeenCalled()
|
||||
|
||||
resolveGetLists([])
|
||||
await syncPromise
|
||||
await pullPromise
|
||||
|
||||
expect(listsApiMocks.getListItemsApi).toHaveBeenCalledWith('list-1')
|
||||
expect(store.listItems.find((item) => item.id === 'server-item-1')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
+47
-33
@@ -1,6 +1,12 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { db, type LocalList, type LocalListItem, type SyncQueueEntry } from '@/database/db'
|
||||
import {
|
||||
db,
|
||||
type LocalList,
|
||||
type LocalListItem,
|
||||
type SyncQueueEntry,
|
||||
type NewSyncQueueEntry,
|
||||
} from '@/database/db'
|
||||
import {
|
||||
getListsApi,
|
||||
createListApi,
|
||||
@@ -86,14 +92,27 @@ export const useListsStore = defineStore('lists', () => {
|
||||
isLoaded.value = true
|
||||
}
|
||||
|
||||
async function loadLists() {
|
||||
if (!isLoaded.value) {
|
||||
await refresh()
|
||||
// Dedupes concurrent first-load refreshes: loadLists() and loadListItems()
|
||||
// are both called from ListDetailView's onMounted and would otherwise each
|
||||
// see isLoaded === false and kick off their own full-table Dexie scan.
|
||||
let loadPromise: Promise<void> | null = null
|
||||
|
||||
async function ensureLoaded() {
|
||||
if (isLoaded.value) return
|
||||
if (!loadPromise) {
|
||||
loadPromise = refresh().finally(() => {
|
||||
loadPromise = null
|
||||
})
|
||||
}
|
||||
await loadPromise
|
||||
}
|
||||
|
||||
async function loadLists() {
|
||||
await ensureLoaded()
|
||||
void sync()
|
||||
}
|
||||
|
||||
async function enqueue(entry: Omit<SyncQueueEntry, 'id' | 'createdAt' | 'attempts'>) {
|
||||
async function enqueue(entry: NewSyncQueueEntry) {
|
||||
await db.syncQueue.add({
|
||||
...entry,
|
||||
createdAt: Date.now(),
|
||||
@@ -263,14 +282,16 @@ export const useListsStore = defineStore('lists', () => {
|
||||
.filter((entry) => entry.localListId === oldId)
|
||||
.toArray()
|
||||
for (const entry of affectedQueueEntries) {
|
||||
const payload = entry.payload as { list_id?: string; id?: string }
|
||||
const payload = entry.payload
|
||||
const updatedPayload =
|
||||
'list_id' in payload
|
||||
? { ...payload, list_id: newId }
|
||||
: 'id' in payload
|
||||
? { ...payload, id: newId }
|
||||
: payload
|
||||
await db.syncQueue.update(entry.id!, {
|
||||
localListId: newId,
|
||||
payload: payload?.list_id
|
||||
? { ...payload, list_id: newId }
|
||||
: payload?.id
|
||||
? { ...payload, id: newId }
|
||||
: entry.payload,
|
||||
payload: updatedPayload,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -295,14 +316,16 @@ export const useListsStore = defineStore('lists', () => {
|
||||
.filter((queueEntry) => queueEntry.localListItemId === oldId)
|
||||
.toArray()
|
||||
for (const queueEntry of affectedQueueEntries) {
|
||||
const payload = queueEntry.payload as { list_item_id?: string; id?: string }
|
||||
const payload = queueEntry.payload
|
||||
const updatedPayload =
|
||||
'list_item_id' in payload
|
||||
? { ...payload, list_item_id: newId }
|
||||
: 'id' in payload
|
||||
? { ...payload, id: newId }
|
||||
: payload
|
||||
await db.syncQueue.update(queueEntry.id!, {
|
||||
localListItemId: newId,
|
||||
payload: payload?.list_item_id
|
||||
? { ...payload, list_item_id: newId }
|
||||
: payload?.id
|
||||
? { ...payload, id: newId }
|
||||
: queueEntry.payload,
|
||||
payload: updatedPayload,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -316,7 +339,7 @@ export const useListsStore = defineStore('lists', () => {
|
||||
async function processSyncEntry(entry: SyncQueueEntry) {
|
||||
switch (entry.type) {
|
||||
case 'createList': {
|
||||
const created = await createListApi(entry.payload as { name: string; user_ids?: string[] })
|
||||
const created = await createListApi(entry.payload)
|
||||
if (entry.localListId) {
|
||||
await remapListId(entry.localListId, created.id)
|
||||
}
|
||||
@@ -327,16 +350,14 @@ export const useListsStore = defineStore('lists', () => {
|
||||
// created item (with its own real id) in the response body, so we
|
||||
// remap our client-generated placeholder id to it instead of keeping
|
||||
// the made-up one around.
|
||||
const created = await createListItemApi(entry.payload as { list_id: string; title: string })
|
||||
const created = await createListItemApi(entry.payload)
|
||||
if (entry.localListItemId) {
|
||||
await remapListItemId(entry.localListItemId, created.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'updateListItem': {
|
||||
await updateListItemApi(
|
||||
entry.payload as { list_item_id: string; title?: string; is_completed?: boolean },
|
||||
)
|
||||
await updateListItemApi(entry.payload)
|
||||
if (entry.localListItemId) {
|
||||
await markListItemSynced(entry.localListItemId)
|
||||
}
|
||||
@@ -344,21 +365,16 @@ export const useListsStore = defineStore('lists', () => {
|
||||
}
|
||||
case 'setListItemCompleted': {
|
||||
if (!entry.localListItemId) break
|
||||
await setListItemCompletedApi(
|
||||
entry.localListItemId,
|
||||
entry.payload as { is_completed: boolean },
|
||||
)
|
||||
await setListItemCompletedApi(entry.localListItemId, entry.payload)
|
||||
await markListItemSynced(entry.localListItemId)
|
||||
break
|
||||
}
|
||||
case 'deleteList': {
|
||||
const payload = entry.payload as { id: string }
|
||||
await deleteListApi(payload.id)
|
||||
await deleteListApi(entry.payload.id)
|
||||
break
|
||||
}
|
||||
case 'deleteListItem': {
|
||||
const payload = entry.payload as { id: string }
|
||||
await deleteListItemApi(payload.id)
|
||||
await deleteListItemApi(entry.payload.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -533,9 +549,7 @@ export const useListsStore = defineStore('lists', () => {
|
||||
}
|
||||
|
||||
async function loadListItems(listId: string) {
|
||||
if (!isLoaded.value) {
|
||||
await refresh()
|
||||
}
|
||||
await ensureLoaded()
|
||||
void pullListItems(listId)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user