Files
windmill/frontend/src/lib/userDraftDbMigration.test.ts
T

186 lines
5.9 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest'
// Service layer is mocked: the migration must dedup against the deployed value
// without making real network calls.
const updateDraft = vi.fn(async (..._args: any[]) => ({ status: 'created' as const }))
const getScriptByPath = vi.fn()
const getFlowByPath = vi.fn()
const getAppByPath = vi.fn()
vi.mock('./gen', () => ({
DraftService: { updateDraft: (...a: unknown[]) => updateDraft(...(a as [])) },
ScriptService: { getScriptByPath: (...a: unknown[]) => getScriptByPath(...(a as [])) },
FlowService: { getFlowByPath: (...a: unknown[]) => getFlowByPath(...(a as [])) },
AppService: { getAppByPath: (...a: unknown[]) => getAppByPath(...(a as [])) }
}))
// `migrateApp` mutates an App in place; the deployed fixtures below are already
// in migrated shape, so a no-op keeps the dedup comparison exact.
vi.mock('./components/apps/migrateApp', () => ({ migrateApp: vi.fn() }))
vi.mock('./toast', () => ({ sendUserToast: vi.fn() }))
vi.mock('./userNamespace', () => ({ getUsernameForNamespace: () => 'me' }))
vi.mock('./utils/uuid', () => ({ randomUUID: () => 'fixed-uuid' }))
import { migrateUserDraftsToDb } from './userDraftDbMigration'
function lsKey(kind: string, path: string): string {
return `userdraft/w/main/${kind}/${path}`
}
function setDraft(kind: string, path: string, value: unknown): string {
const key = lsKey(kind, path)
localStorage.setItem(key, JSON.stringify({ value, lastWrittenAt: 123 }))
return key
}
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
updateDraft.mockResolvedValue({ status: 'created' })
})
describe('migrateUserDraftsToDb dedup', () => {
it('drops a draft deep-equal to the deployed script without uploading it', async () => {
const deployed = { path: 'u/me/s', summary: 'hi', content: 'x', language: 'bun' }
getScriptByPath.mockResolvedValue(deployed)
const key = setDraft('script', 'u/me/s', { ...deployed })
await migrateUserDraftsToDb()
expect(getScriptByPath).toHaveBeenCalledWith({
workspace: 'main',
path: 'u/me/s',
getDraft: false
})
expect(updateDraft).not.toHaveBeenCalled()
expect(localStorage.getItem(key)).toBeNull()
})
it('uploads a draft that differs from the deployed script', async () => {
getScriptByPath.mockResolvedValue({
path: 'u/me/s',
summary: 'hi',
content: 'x',
language: 'bun'
})
const key = setDraft('script', 'u/me/s', {
path: 'u/me/s',
summary: 'hi',
content: 'EDITED',
language: 'bun'
})
await migrateUserDraftsToDb()
expect(updateDraft).toHaveBeenCalledTimes(1)
expect(localStorage.getItem(key)).toBeNull()
})
it('treats `{ field: undefined }` and an absent field as equal (json normalization)', async () => {
// The draft table stores JSON, which strips `undefined` keys — the
// comparison must too, or a draft that only differs by an undefined key
// would never dedup.
getFlowByPath.mockResolvedValue({ summary: 'f', value: { modules: [] } })
const key = setDraft('flow', 'u/me/f', {
summary: 'f',
value: { modules: [] },
labels: undefined
})
await migrateUserDraftsToDb()
expect(updateDraft).not.toHaveBeenCalled()
expect(localStorage.getItem(key)).toBeNull()
})
it('ignores server-managed metadata fields on the deployed flow payload', async () => {
// The deployed flow carries read-time metadata (workspace_id, edited_by,
// version_id, is_draft, timestamps) that the editor's draft content never
// holds — they must not block the dedup.
getFlowByPath.mockResolvedValue({
workspace_id: 'admins',
path: 'u/me/f',
summary: 'f',
value: { modules: [] },
edited_by: 'admin@windmill.dev',
edited_at: '2026-01-01T00:00:00Z',
archived: false,
schema: {},
extra_perms: {},
version_id: 2,
is_draft: false,
draft_saved_at: '2026-01-01T00:00:01Z'
})
const key = setDraft('flow', 'u/me/f', {
path: 'u/me/f',
summary: 'f',
value: { modules: [] },
archived: false,
schema: {}
})
await migrateUserDraftsToDb()
expect(updateDraft).not.toHaveBeenCalled()
expect(localStorage.getItem(key)).toBeNull()
})
it('compares app drafts against the deployed `.value`', async () => {
const appValue = {
grid: [],
fullscreen: false,
unusedInlineScripts: [],
hiddenInlineScripts: []
}
getAppByPath.mockResolvedValue({ value: { ...appValue } })
const key = setDraft('app', 'u/me/a', { ...appValue })
await migrateUserDraftsToDb()
expect(getAppByPath).toHaveBeenCalledWith({
workspace: 'main',
path: 'u/me/a',
getDraft: false
})
expect(updateDraft).not.toHaveBeenCalled()
expect(localStorage.getItem(key)).toBeNull()
})
it('uploads when there is no deployed item (fetch rejects)', async () => {
getScriptByPath.mockRejectedValue(new Error('404'))
const key = setDraft('script', 'u/me/new', { path: 'u/me/new', content: 'x' })
await migrateUserDraftsToDb()
expect(updateDraft).toHaveBeenCalledTimes(1)
expect(localStorage.getItem(key)).toBeNull()
})
it('skips the deployed fetch for a pathless /add draft and uploads at a minted path', async () => {
// A legacy `/add` autosave has an empty path; there is no deployed item to
// dedup against, so it uploads to a freshly minted `u/{user}/draft_{uuid}`.
setDraft('script', '', { path: '', content: 'x' })
await migrateUserDraftsToDb()
expect(getScriptByPath).not.toHaveBeenCalled()
expect(updateDraft).toHaveBeenCalledTimes(1)
expect(updateDraft.mock.calls[0][0]).toMatchObject({
kind: 'script',
// `mintDraftAddPath` dashes→underscores (path segments are word chars).
path: 'u/me/draft_fixed_uuid'
})
})
it('does not dedup unsupported kinds (e.g. variable) — uploads as before', async () => {
const key = setDraft('variable', 'u/me/v', { value: 'secret' })
await migrateUserDraftsToDb()
expect(getScriptByPath).not.toHaveBeenCalled()
expect(getAppByPath).not.toHaveBeenCalled()
expect(updateDraft).toHaveBeenCalledTimes(1)
expect(localStorage.getItem(key)).toBeNull()
})
})