mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 16:02:14 +00:00
186 lines
5.9 KiB
TypeScript
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()
|
|
})
|
|
})
|