From 933a94fe8cd0c653325f2f9f8bf1b765242b2c6c Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 22 Jul 2026 10:48:53 +0300 Subject: [PATCH 1/5] feat(api): add updated_at filter and sort to GET /device Allow public device listing to filter by updated_at greater than an ISO timestamp and sort by updated_at asc/desc, avoiding full client-side paging. --- .../functions/_backend/public/device/get.ts | 35 +++++++- .../functions/_backend/utils/cloudflare.ts | 13 ++- supabase/functions/_backend/utils/supabase.ts | 3 + supabase/functions/_backend/utils/types.ts | 2 + .../cloudflare-device-pagination.unit.test.ts | 41 +++++++++ tests/device.test.ts | 90 +++++++++++++++++++ 6 files changed, 182 insertions(+), 2 deletions(-) diff --git a/supabase/functions/_backend/public/device/get.ts b/supabase/functions/_backend/public/device/get.ts index 5b2cd9d865..dffc0e8fe6 100644 --- a/supabase/functions/_backend/public/device/get.ts +++ b/supabase/functions/_backend/public/device/get.ts @@ -16,6 +16,10 @@ interface GetDevice { cursor?: string /** Limit for results (default uses fetchLimit) */ limit?: number + /** ISO timestamp - only return devices with updated_at greater than this value */ + updated_at?: string + /** Sort devices by updated_at: asc or desc */ + order?: string } interface publicDevice { @@ -44,6 +48,25 @@ export function filterDeviceKeys(devices: DeviceRes[]) { }) } +function parseUpdatedAtFilter(updatedAt: string | undefined): string | undefined { + if (!updatedAt) + return undefined + + const parsed = new Date(updatedAt) + if (Number.isNaN(parsed.getTime())) + throw simpleError('invalid_updated_at', 'updated_at must be a valid ISO date', { updated_at: updatedAt }) + + return parsed.toISOString() +} + +function parseDevicesOrder(order: string | undefined) { + if (!order) + return undefined + if (order !== 'asc' && order !== 'desc') + throw simpleError('invalid_order', 'order must be asc or desc', { order }) + return [{ key: 'updated_at', sortable: order as 'asc' | 'desc' }] +} + export async function get(c: Context, body: GetDevice, apikey: Database['public']['Tables']['apikeys']['Row']): Promise { if (!body.app_id) { throw simpleError('missing_app_id', 'Missing app_id', { body }) @@ -51,6 +74,14 @@ export async function get(c: Context, body: GetDevice, apikey: Database['public' if (!isValidAppId(body.app_id)) { throw simpleError('invalid_app_id', 'App ID must be a reverse domain string', { app_id: body.app_id }) } + + const updatedAtGt = parseUpdatedAtFilter(body.updated_at) + const order = parseDevicesOrder(body.order) + const limit = body.limit == null ? fetchLimit : Number(body.limit) + if (!Number.isFinite(limit) || limit < 1) { + throw simpleError('invalid_limit', 'limit must be a positive number', { limit: body.limit }) + } + // Auth context is already set by middlewareKey if (!(await checkPermission(c, 'app.read_devices', { appId: body.app_id }))) { throw simpleError('cannot_access_app', 'You can\'t access this app', { app_id: body.app_id }) @@ -103,7 +134,9 @@ export async function get(c: Context, body: GetDevice, apikey: Database['public' const res = await readDevices(c, { app_id: body.app_id, cursor: body.cursor, - limit: body.limit ?? fetchLimit, + limit, + updated_at_gt: updatedAtGt, + order, }, body.customIdMode ?? false) if (!res?.data) { diff --git a/supabase/functions/_backend/utils/cloudflare.ts b/supabase/functions/_backend/utils/cloudflare.ts index e9db74d112..de59a24e06 100644 --- a/supabase/functions/_backend/utils/cloudflare.ts +++ b/supabase/functions/_backend/utils/cloudflare.ts @@ -959,8 +959,19 @@ function buildReadDevicesCFCursorCondition(cursor: string | undefined, devicesOr return `(updated_at ${comparison} toDateTime('${safeCursorTime}') OR (updated_at = toDateTime('${safeCursorTime}') AND device_id > '${safeCursorDeviceId}'))` } +function buildReadDevicesCFUpdatedAtGtCondition(updatedAtGt: string | undefined) { + if (!updatedAtGt) + return '' + + const safeUpdatedAtGt = escapeSqlString(formatDateCF(updatedAtGt)) + return `updated_at > toDateTime('${safeUpdatedAtGt}')` +} + function buildReadDevicesCFOuterConditions(params: ReadDevicesParams, devicesOrder: DevicesOrderCF | null) { - const conditions = [buildReadDevicesCFCursorCondition(params.cursor, devicesOrder)] + const conditions = [ + buildReadDevicesCFCursorCondition(params.cursor, devicesOrder), + buildReadDevicesCFUpdatedAtGtCondition(params.updated_at_gt), + ] return conditions.filter(Boolean) } diff --git a/supabase/functions/_backend/utils/supabase.ts b/supabase/functions/_backend/utils/supabase.ts index 951337eb78..8960386828 100644 --- a/supabase/functions/_backend/utils/supabase.ts +++ b/supabase/functions/_backend/utils/supabase.ts @@ -1605,6 +1605,9 @@ export async function readDevicesSB(c: Context, params: ReadDevicesParams, custo if (params.installSources?.length) query = query.in('install_source', params.installSources) + if (params.updated_at_gt) + query = query.gt('updated_at', params.updated_at_gt) + const devicesOrder = getDevicesOrder(params.order) if (params.cursor) { diff --git a/supabase/functions/_backend/utils/types.ts b/supabase/functions/_backend/utils/types.ts index ca969dbe27..6d080ab4a2 100644 --- a/supabase/functions/_backend/utils/types.ts +++ b/supabase/functions/_backend/utils/types.ts @@ -134,6 +134,8 @@ export interface ReadDevicesParams { installSources?: string[] search?: string order?: Order[] + /** Only return devices with updated_at greater than this ISO timestamp */ + updated_at_gt?: string limit?: number /** Cursor for pagination - use the last updated_at from previous page */ cursor?: string diff --git a/tests/cloudflare-device-pagination.unit.test.ts b/tests/cloudflare-device-pagination.unit.test.ts index c33af22326..a398c2a617 100644 --- a/tests/cloudflare-device-pagination.unit.test.ts +++ b/tests/cloudflare-device-pagination.unit.test.ts @@ -133,6 +133,20 @@ describe('buildReadDevicesCFQuery', () => { expect(query).toContain('GROUP BY blob1') expect(query).toContain(`install_source IN ('app_store', 'amazon_appstore')`) }) + it.concurrent('filters devices with updated_at greater than the provided timestamp', () => { + const query = buildReadDevicesCFQuery({ + app_id: 'com.example.app', + updated_at_gt: '2026-04-04T03:05:59.000Z', + limit: 1, + }, false) + + const groupByIndex = query.indexOf('GROUP BY blob1') + const filterIndex = query.indexOf(`WHERE updated_at > toDateTime('2026-04-04 03:05:59')`) + + expect(groupByIndex).toBeGreaterThan(-1) + expect(filterIndex).toBeGreaterThan(groupByIndex) + }) + }) describe('countDevicesCF', () => { it('does not read install source blob on default device counts', async () => { @@ -291,4 +305,31 @@ describe('readDevicesSB', () => { expect(query.in).toHaveBeenCalledWith('install_source', ['app_store', 'testflight']) }) + + it('filters devices with updated_at greater than the provided timestamp', async () => { + const { client, query } = createReadDevicesQueryMock() + vi.mocked(createClient).mockReturnValue(client as unknown as ReturnType) + + await readDevicesSB(createContextMock() as unknown as Context, { + app_id: 'com.example.app', + updated_at_gt: '2026-04-04T03:05:59.000Z', + limit: 1, + }, false) + + expect(query.gt).toHaveBeenCalledWith('updated_at', '2026-04-04T03:05:59.000Z') + }) + + it('orders devices by updated_at when requested', async () => { + const { client, query } = createReadDevicesQueryMock() + vi.mocked(createClient).mockReturnValue(client as unknown as ReturnType) + + await readDevicesSB(createContextMock() as unknown as Context, { + app_id: 'com.example.app', + order: [{ key: 'updated_at', sortable: 'desc' }], + limit: 1, + }, false) + + expect(query.order).toHaveBeenCalledWith('updated_at', { ascending: false }) + expect(query.order).toHaveBeenCalledWith('device_id', { ascending: true }) + }) }) diff --git a/tests/device.test.ts b/tests/device.test.ts index e9116ad1a1..e225812588 100644 --- a/tests/device.test.ts +++ b/tests/device.test.ts @@ -67,6 +67,96 @@ describe.concurrent('[GET] /device operations', () => { }) }) + +describe('[GET] /device updated_at filter and order', () => { + it('filters devices by updated_at greater than ISO date', async () => { + const supabase = getSupabaseClient() + const pastDeviceId = '00000000-0000-0000-0000-000000000000' + const futureDeviceId = randomUUID().toLowerCase() + const cutoff = new Date('2026-01-01T00:00:00.000Z') + + const { error: pastError } = await supabase.from('devices').update({ + updated_at: '2025-06-01T00:00:00.000Z', + }).eq('app_id', APPNAME_DEVICE).eq('device_id', pastDeviceId) + expect(pastError).toBeNull() + + const { error: futureError } = await supabase.from('devices').upsert({ + app_id: APPNAME_DEVICE, + device_id: futureDeviceId, + platform: 'android', + plugin_version: '6.0.0', + os_version: '14', + version_build: '1.0.0', + version_name: '1.0.0', + is_prod: true, + is_emulator: false, + updated_at: '2026-06-01T00:00:00.000Z', + }) + expect(futureError).toBeNull() + + const params = new URLSearchParams({ + app_id: APPNAME_DEVICE, + updated_at: cutoff.toISOString(), + }) + const response = await fetch(`${BASE_URL}/device?${params.toString()}`, { + method: 'GET', + headers, + }) + const data = await response.json<{ data: { device_id: string, updated_at: string }[], error?: string }>() + expect(response.status).toBe(200) + expect(data.error).toBeUndefined() + expect(data.data.some(device => device.device_id === futureDeviceId)).toBe(true) + expect(data.data.some(device => device.device_id === pastDeviceId)).toBe(false) + expect(data.data.every(device => new Date(device.updated_at).getTime() > cutoff.getTime())).toBe(true) + }) + + it('sorts devices by updated_at desc', async () => { + const params = new URLSearchParams({ + app_id: APPNAME_DEVICE, + order: 'desc', + }) + const response = await fetch(`${BASE_URL}/device?${params.toString()}`, { + method: 'GET', + headers, + }) + const data = await response.json<{ data: { updated_at: string }[], error?: string }>() + expect(response.status).toBe(200) + expect(data.error).toBeUndefined() + expect(data.data.length).toBeGreaterThan(1) + for (let i = 1; i < data.data.length; i++) { + expect(new Date(data.data[i - 1].updated_at).getTime()).toBeGreaterThanOrEqual(new Date(data.data[i].updated_at).getTime()) + } + }) + + it('rejects invalid updated_at filter', async () => { + const params = new URLSearchParams({ + app_id: APPNAME_DEVICE, + updated_at: 'not-a-date', + }) + const response = await fetch(`${BASE_URL}/device?${params.toString()}`, { + method: 'GET', + headers, + }) + const data = await response.json<{ error?: string }>() + expect(response.status).toBe(400) + expect(data.error).toBe('invalid_updated_at') + }) + + it('rejects invalid order', async () => { + const params = new URLSearchParams({ + app_id: APPNAME_DEVICE, + order: 'sideways', + }) + const response = await fetch(`${BASE_URL}/device?${params.toString()}`, { + method: 'GET', + headers, + }) + const data = await response.json<{ error?: string }>() + expect(response.status).toBe(400) + expect(data.error).toBe('invalid_order') + }) +}) + describe('[POST] /device operations', () => { it('link device', async () => { const deviceId = randomUUID().toLowerCase() From e0c9225bd9d4305db4bbba05716de174dda5a3ac Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 22 Jul 2026 10:56:46 +0300 Subject: [PATCH 2/5] fix(frontend): include created_by_apikey_rbac_id on builtin version Keep createBuiltinChannelVersion aligned with the app_versions row type after the schema sync added the API-key creator column. --- src/services/versions.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/services/versions.ts b/src/services/versions.ts index a04188e85f..3bed6f3b60 100644 --- a/src/services/versions.ts +++ b/src/services/versions.ts @@ -26,6 +26,7 @@ export function createBuiltinChannelVersion(channel: { cli_version: null, comment: null, created_at: channel.created_at, + created_by_apikey_rbac_id: null, deleted: false, deleted_at: null, external_url: null, From 705dc2f6ca8acb3bc9f0d045bb0c3c6367754548 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 22 Jul 2026 11:23:39 +0300 Subject: [PATCH 3/5] fix(api): tighten device updated_at and limit validation Reject non-calendar ISO timestamps and non-integer limits on GET /device so filter/pagination inputs cannot be silently normalized. --- .../functions/_backend/public/device/get.ts | 21 +++++++++++++++++-- tests/device.test.ts | 14 +++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/supabase/functions/_backend/public/device/get.ts b/supabase/functions/_backend/public/device/get.ts index dffc0e8fe6..8a6fb13667 100644 --- a/supabase/functions/_backend/public/device/get.ts +++ b/supabase/functions/_backend/public/device/get.ts @@ -52,10 +52,27 @@ function parseUpdatedAtFilter(updatedAt: string | undefined): string | undefined if (!updatedAt) return undefined + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?Z$/.exec(updatedAt) + if (!match) { + throw simpleError('invalid_updated_at', 'updated_at must be a valid ISO date', { updated_at: updatedAt }) + } + const parsed = new Date(updatedAt) if (Number.isNaN(parsed.getTime())) throw simpleError('invalid_updated_at', 'updated_at must be a valid ISO date', { updated_at: updatedAt }) + const [, year, month, day, hour, minute, second] = match + if ( + parsed.getUTCFullYear() !== Number(year) + || parsed.getUTCMonth() + 1 !== Number(month) + || parsed.getUTCDate() !== Number(day) + || parsed.getUTCHours() !== Number(hour) + || parsed.getUTCMinutes() !== Number(minute) + || parsed.getUTCSeconds() !== Number(second) + ) { + throw simpleError('invalid_updated_at', 'updated_at must be a valid ISO date', { updated_at: updatedAt }) + } + return parsed.toISOString() } @@ -78,8 +95,8 @@ export async function get(c: Context, body: GetDevice, apikey: Database['public' const updatedAtGt = parseUpdatedAtFilter(body.updated_at) const order = parseDevicesOrder(body.order) const limit = body.limit == null ? fetchLimit : Number(body.limit) - if (!Number.isFinite(limit) || limit < 1) { - throw simpleError('invalid_limit', 'limit must be a positive number', { limit: body.limit }) + if (!Number.isFinite(limit) || !Number.isInteger(limit) || limit < 1) { + throw simpleError('invalid_limit', 'limit must be a positive integer', { limit: body.limit }) } // Auth context is already set by middlewareKey diff --git a/tests/device.test.ts b/tests/device.test.ts index e225812588..c334373c00 100644 --- a/tests/device.test.ts +++ b/tests/device.test.ts @@ -142,6 +142,20 @@ describe('[GET] /device updated_at filter and order', () => { expect(data.error).toBe('invalid_updated_at') }) + it('rejects non-calendar ISO updated_at values', async () => { + const params = new URLSearchParams({ + app_id: APPNAME_DEVICE, + updated_at: '2024-02-31T00:00:00.000Z', + }) + const response = await fetch(`${BASE_URL}/device?${params.toString()}`, { + method: 'GET', + headers, + }) + const data = await response.json<{ error?: string }>() + expect(response.status).toBe(400) + expect(data.error).toBe('invalid_updated_at') + }) + it('rejects invalid order', async () => { const params = new URLSearchParams({ app_id: APPNAME_DEVICE, From acac0f80fd5ed46d7191dc59ead23ec74d013348 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 22 Jul 2026 11:34:14 +0300 Subject: [PATCH 4/5] fix(api): validate device list filters only for list requests Keep single-device GET backward compatible by ignoring updated_at, order, and limit unless the caller is listing devices. --- .../functions/_backend/public/device/get.ts | 14 +++++++------- tests/device.test.ts | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/supabase/functions/_backend/public/device/get.ts b/supabase/functions/_backend/public/device/get.ts index 8a6fb13667..15fa714832 100644 --- a/supabase/functions/_backend/public/device/get.ts +++ b/supabase/functions/_backend/public/device/get.ts @@ -92,13 +92,6 @@ export async function get(c: Context, body: GetDevice, apikey: Database['public' throw simpleError('invalid_app_id', 'App ID must be a reverse domain string', { app_id: body.app_id }) } - const updatedAtGt = parseUpdatedAtFilter(body.updated_at) - const order = parseDevicesOrder(body.order) - const limit = body.limit == null ? fetchLimit : Number(body.limit) - if (!Number.isFinite(limit) || !Number.isInteger(limit) || limit < 1) { - throw simpleError('invalid_limit', 'limit must be a positive integer', { limit: body.limit }) - } - // Auth context is already set by middlewareKey if (!(await checkPermission(c, 'app.read_devices', { appId: body.app_id }))) { throw simpleError('cannot_access_app', 'You can\'t access this app', { app_id: body.app_id }) @@ -148,6 +141,13 @@ export async function get(c: Context, body: GetDevice, apikey: Database['public' return c.json(dataDevice) } else { + const updatedAtGt = parseUpdatedAtFilter(body.updated_at) + const order = parseDevicesOrder(body.order) + const limit = body.limit == null ? fetchLimit : Number(body.limit) + if (!Number.isFinite(limit) || !Number.isInteger(limit) || limit < 1) { + throw simpleError('invalid_limit', 'limit must be a positive integer', { limit: body.limit }) + } + const res = await readDevices(c, { app_id: body.app_id, cursor: body.cursor, diff --git a/tests/device.test.ts b/tests/device.test.ts index c334373c00..f1ea96e068 100644 --- a/tests/device.test.ts +++ b/tests/device.test.ts @@ -169,6 +169,23 @@ describe('[GET] /device updated_at filter and order', () => { expect(response.status).toBe(400) expect(data.error).toBe('invalid_order') }) + + it('ignores list-only params when fetching a specific device', async () => { + const params = new URLSearchParams({ + app_id: APPNAME_DEVICE, + device_id: '00000000-0000-0000-0000-000000000000', + order: 'sideways', + updated_at: 'not-a-date', + limit: '1.5', + }) + const response = await fetch(`${BASE_URL}/device?${params.toString()}`, { + method: 'GET', + headers, + }) + const data = await response.json<{ device_id?: string, error?: string }>() + expect(response.status).toBe(200) + expect(data.device_id).toBe('00000000-0000-0000-0000-000000000000') + }) }) describe('[POST] /device operations', () => { From 075570d12573944b92160d45e2bb749abb7e8a99 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 22 Jul 2026 11:47:26 +0300 Subject: [PATCH 5/5] fix(api): harden device updated_at sync and CF filtering Normalize public device timestamps to ISO UTC, accept Cloudflare's space-separated UTC format for filter round-trips, push updated_at_gt into the Analytics Engine inner query, and avoid mutating shared seed devices. --- .../functions/_backend/public/device/get.ts | 43 ++++++++++++++--- .../functions/_backend/utils/cloudflare.ts | 5 ++ .../cloudflare-device-pagination.unit.test.ts | 7 ++- tests/device.test.ts | 47 +++++++++++-------- 4 files changed, 74 insertions(+), 28 deletions(-) diff --git a/supabase/functions/_backend/public/device/get.ts b/supabase/functions/_backend/public/device/get.ts index 15fa714832..7ef867843a 100644 --- a/supabase/functions/_backend/public/device/get.ts +++ b/supabase/functions/_backend/public/device/get.ts @@ -41,23 +41,29 @@ interface publicDevice { channel?: string } -export function filterDeviceKeys(devices: DeviceRes[]) { - return devices.map((device) => { - const { updated_at, device_id, custom_id, is_prod, is_emulator, install_source, version_name, version, app_id, platform, plugin_version, os_version, version_build, key_id, country_code } = device - return { updated_at, device_id, custom_id, is_prod, is_emulator, install_source, version_name, version, app_id, platform, plugin_version, os_version, version_build, key_id, country_code } - }) +function toPublicUpdatedAt(value: string): string { + // Cloudflare Analytics Engine returns UTC as "YYYY-MM-DD HH:mm:ss". + const cloudflareUtc = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})$/.exec(value) + const normalized = cloudflareUtc ? `${cloudflareUtc[1]}T${cloudflareUtc[2]}.000Z` : value + return new Date(normalized).toISOString() } function parseUpdatedAtFilter(updatedAt: string | undefined): string | undefined { if (!updatedAt) return undefined - const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?Z$/.exec(updatedAt) + // Accept ISO-8601 UTC (Z) and the Cloudflare device timestamp format for sync round-trips. + const cloudflareUtc = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec(updatedAt) + const normalized = cloudflareUtc + ? `${cloudflareUtc[1]}-${cloudflareUtc[2]}-${cloudflareUtc[3]}T${cloudflareUtc[4]}:${cloudflareUtc[5]}:${cloudflareUtc[6]}.000Z` + : updatedAt + + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?Z$/.exec(normalized) if (!match) { throw simpleError('invalid_updated_at', 'updated_at must be a valid ISO date', { updated_at: updatedAt }) } - const parsed = new Date(updatedAt) + const parsed = new Date(normalized) if (Number.isNaN(parsed.getTime())) throw simpleError('invalid_updated_at', 'updated_at must be a valid ISO date', { updated_at: updatedAt }) @@ -84,6 +90,29 @@ function parseDevicesOrder(order: string | undefined) { return [{ key: 'updated_at', sortable: order as 'asc' | 'desc' }] } +export function filterDeviceKeys(devices: DeviceRes[]) { + return devices.map((device) => { + const { updated_at, device_id, custom_id, is_prod, is_emulator, install_source, version_name, version, app_id, platform, plugin_version, os_version, version_build, key_id, country_code } = device + return { + updated_at: updated_at ? toPublicUpdatedAt(updated_at) : updated_at, + device_id, + custom_id, + is_prod, + is_emulator, + install_source, + version_name, + version, + app_id, + platform, + plugin_version, + os_version, + version_build, + key_id, + country_code, + } + }) +} + export async function get(c: Context, body: GetDevice, apikey: Database['public']['Tables']['apikeys']['Row']): Promise { if (!body.app_id) { throw simpleError('missing_app_id', 'Missing app_id', { body }) diff --git a/supabase/functions/_backend/utils/cloudflare.ts b/supabase/functions/_backend/utils/cloudflare.ts index de59a24e06..1234168341 100644 --- a/supabase/functions/_backend/utils/cloudflare.ts +++ b/supabase/functions/_backend/utils/cloudflare.ts @@ -1007,6 +1007,11 @@ export function buildReadDevicesCFQuery(params: ReadDevicesParams, customIdMode: conditions.push(`blob2 = '${escapeSqlString(params.version_name)}'`) } + if (params.updated_at_gt) { + const safeUpdatedAtGt = escapeSqlString(formatDateCF(params.updated_at_gt)) + conditions.push(`timestamp > toDateTime('${safeUpdatedAtGt}')`) + } + const devicesOrder = getReadDevicesCFOrder(params) const outerConditions = buildReadDevicesCFOuterConditions(params, devicesOrder) const includeCountryCode = !!params.deviceIds?.length diff --git a/tests/cloudflare-device-pagination.unit.test.ts b/tests/cloudflare-device-pagination.unit.test.ts index a398c2a617..2716d7e407 100644 --- a/tests/cloudflare-device-pagination.unit.test.ts +++ b/tests/cloudflare-device-pagination.unit.test.ts @@ -141,10 +141,13 @@ describe('buildReadDevicesCFQuery', () => { }, false) const groupByIndex = query.indexOf('GROUP BY blob1') - const filterIndex = query.indexOf(`WHERE updated_at > toDateTime('2026-04-04 03:05:59')`) + const innerFilterIndex = query.indexOf(`timestamp > toDateTime('2026-04-04 03:05:59')`) + const outerFilterIndex = query.indexOf(`WHERE updated_at > toDateTime('2026-04-04 03:05:59')`) expect(groupByIndex).toBeGreaterThan(-1) - expect(filterIndex).toBeGreaterThan(groupByIndex) + expect(innerFilterIndex).toBeGreaterThan(-1) + expect(innerFilterIndex).toBeLessThan(groupByIndex) + expect(outerFilterIndex).toBeGreaterThan(groupByIndex) }) }) diff --git a/tests/device.test.ts b/tests/device.test.ts index f1ea96e068..7136e1b721 100644 --- a/tests/device.test.ts +++ b/tests/device.test.ts @@ -71,28 +71,37 @@ describe.concurrent('[GET] /device operations', () => { describe('[GET] /device updated_at filter and order', () => { it('filters devices by updated_at greater than ISO date', async () => { const supabase = getSupabaseClient() - const pastDeviceId = '00000000-0000-0000-0000-000000000000' + const pastDeviceId = randomUUID().toLowerCase() const futureDeviceId = randomUUID().toLowerCase() const cutoff = new Date('2026-01-01T00:00:00.000Z') - const { error: pastError } = await supabase.from('devices').update({ - updated_at: '2025-06-01T00:00:00.000Z', - }).eq('app_id', APPNAME_DEVICE).eq('device_id', pastDeviceId) - expect(pastError).toBeNull() - - const { error: futureError } = await supabase.from('devices').upsert({ - app_id: APPNAME_DEVICE, - device_id: futureDeviceId, - platform: 'android', - plugin_version: '6.0.0', - os_version: '14', - version_build: '1.0.0', - version_name: '1.0.0', - is_prod: true, - is_emulator: false, - updated_at: '2026-06-01T00:00:00.000Z', - }) - expect(futureError).toBeNull() + const { error: insertError } = await supabase.from('devices').upsert([ + { + app_id: APPNAME_DEVICE, + device_id: pastDeviceId, + platform: 'ios', + plugin_version: '6.0.0', + os_version: '17.0', + version_build: '1.0.0', + version_name: '1.0.0', + is_prod: true, + is_emulator: false, + updated_at: '2025-06-01T00:00:00.000Z', + }, + { + app_id: APPNAME_DEVICE, + device_id: futureDeviceId, + platform: 'android', + plugin_version: '6.0.0', + os_version: '14', + version_build: '1.0.0', + version_name: '1.0.0', + is_prod: true, + is_emulator: false, + updated_at: '2026-06-01T00:00:00.000Z', + }, + ]) + expect(insertError).toBeNull() const params = new URLSearchParams({ app_id: APPNAME_DEVICE,