143 lines
8.4 KiB
JavaScript
143 lines
8.4 KiB
JavaScript
// Actual disposable Nextcloud pagination; never an installed-provider probe.
|
|
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs/promises')
|
|
const { randomUUID } = require('node:crypto')
|
|
const COUNT = 251 // Production Nextcloud PAGE_ITEMS is 250; do not shrink it for tests.
|
|
const nameOf = index => `page-${String(index).padStart(4, '0')}.txt`
|
|
const bytesOf = name => `Owned pagination fixture: ${name}\n`
|
|
const pathOf = folder => `/remote.php/dav/files/alice/${encodeURIComponent(folder)}/`
|
|
|
|
async function createNew(api, method, path, data, timeout = 5000, onFailure = () => {}) {
|
|
const response = await api.fetch(path, { method, data, timeout, maxRedirects: 0, maxRetries: 0,
|
|
headers: { 'Content-Type': 'text/plain', 'If-None-Match': '*' } })
|
|
try { assert.equal(response.status(), 201, `${method} must create owned data, never overwrite`) }
|
|
catch (error) { onFailure(error); throw error }
|
|
finally { await response.dispose() }
|
|
}
|
|
|
|
// Exported for fake-transport control tests only. Real entry is guarded below.
|
|
async function seedPagination(api, folder) {
|
|
assert.match(folder, /^archy-pagination-[a-f0-9-]{36}$/)
|
|
const deadline = Date.now() + 30000
|
|
const remaining = () => {
|
|
const value = deadline - Date.now()
|
|
assert.ok(value > 0, 'Pagination seed deadline exceeded')
|
|
return Math.min(value, 5000)
|
|
}
|
|
await createNew(api, 'MKCOL', pathOf(folder), '', remaining())
|
|
let next = 0, failure = null
|
|
await Promise.all(Array.from({ length: 4 }, async () => {
|
|
while (!failure && next < COUNT) {
|
|
const name = nameOf(next++)
|
|
try { await createNew(api, 'PUT', pathOf(folder) + name, bytesOf(name), remaining(), error => { failure ??= error }) }
|
|
catch (error) { failure ??= error }
|
|
}
|
|
}))
|
|
if (failure) throw failure
|
|
return Array.from({ length: COUNT }, (_, index) => nameOf(index))
|
|
}
|
|
|
|
async function exerciseNextcloudPagination({ page, request, expect, connectionId, rpc }) {
|
|
assert.equal(process.env.ARCHY_TEST_ISOLATED, '1')
|
|
assert.equal(process.env.ARCHY_TEST_CLOUD_UPSTREAM, 'nextcloud29')
|
|
const canary = process.env.ARCHY_TEST_HOST_CANARY || ''
|
|
assert.match(canary, /^\/tmp\/archy-cloud-test\.[^/]+\/host-only-canary$/)
|
|
await assert.rejects(fs.access(canary), { code: 'ENOENT' })
|
|
// Separate request context: never forward the backend's session cookies.
|
|
const api = await request.newContext({ baseURL: 'http://127.0.0.1', timeout: 5000,
|
|
extraHTTPHeaders: { Authorization: 'Basic ' + Buffer.from('alice:Synthetic-Cloud-A-Only-2026!').toString('base64') } })
|
|
const folder = `archy-pagination-${randomUUID()}`
|
|
const section = page.getByRole('region', { name: 'Connected libraries', exact: true })
|
|
const downloads = section.getByRole('button', { name: /^Download page-\d{4}\.txt$/ })
|
|
const names = () => downloads.evaluateAll(nodes => nodes.map(node => node.getAttribute('aria-label').slice('Download '.length)))
|
|
const responseFor = number => page.waitForResponse(response => {
|
|
if (new URL(response.url()).pathname !== '/rpc/v1' || response.request().method() !== 'POST') return false
|
|
const body = response.request().postDataJSON()
|
|
return body?.method === 'cloud.sources.browse' && body.params?.connection_id === connectionId
|
|
&& body.params.page === number && JSON.stringify(body.params.folder) === JSON.stringify([folder])
|
|
})
|
|
const resultOf = async response => {
|
|
assert.equal(response.status(), 200)
|
|
const body = await response.json()
|
|
assert.ok(!body.error && body.result, 'Actual browse must succeed')
|
|
return body.result
|
|
}
|
|
try {
|
|
const expected = await seedPagination(api, folder)
|
|
await section.getByRole('button', { name: 'Refresh', exact: true }).click()
|
|
await expect(section.getByRole('button', { name: folder, exact: true })).toBeVisible()
|
|
const firstResponse = responseFor(1)
|
|
await section.getByRole('button', { name: folder, exact: true }).click()
|
|
const first = await resultOf(await firstResponse)
|
|
assert.equal(first.items.length, 250)
|
|
assert.equal(first.next_page, 2)
|
|
assert.match(first.listing_revision, /^[a-f0-9]{64}$/)
|
|
await expect(downloads).toHaveCount(250)
|
|
assert.deepEqual((await names()).sort(), first.items.map(item => item.name).sort())
|
|
const secondResponse = responseFor(2)
|
|
await section.getByRole('button', { name: 'Next', exact: true }).click()
|
|
const wire = await secondResponse
|
|
assert.equal(wire.request().postDataJSON().params.listing_revision, first.listing_revision)
|
|
const second = await resultOf(wire)
|
|
assert.equal(second.items.length, 1)
|
|
assert.equal(second.next_page, null)
|
|
assert.equal(second.listing_revision, first.listing_revision)
|
|
const all = [...first.items, ...second.items]
|
|
assert.equal(new Set(all.map(item => item.upstream_id)).size, COUNT)
|
|
assert.deepEqual(all.map(item => item.name).sort(), expected.sort())
|
|
await expect(downloads).toHaveCount(1)
|
|
await expect(section.getByText('Page 2', { exact: true })).toBeVisible()
|
|
await expect(section.getByRole('button', { name: 'Next', exact: true })).toBeDisabled()
|
|
// Upstream identity ordering is NOT filename ordering. Use the actual tail.
|
|
const tail = second.items[0].name
|
|
const pending = page.waitForEvent('download')
|
|
await section.getByRole('button', { name: `Download ${tail}`, exact: true }).click()
|
|
const download = await pending
|
|
assert.equal(download.suggestedFilename(), tail)
|
|
assert.equal(await fs.readFile(await download.path(), 'utf8'), bytesOf(tail))
|
|
const back = responseFor(1)
|
|
await section.getByRole('button', { name: 'Previous', exact: true }).click()
|
|
assert.equal((await resultOf(await back)).listing_revision, first.listing_revision)
|
|
await expect(downloads).toHaveCount(250)
|
|
await createNew(api, 'PUT', pathOf(folder) + 'changed.txt', 'Owned listing mutation\n')
|
|
const stale = responseFor(2)
|
|
await section.getByRole('button', { name: 'Next', exact: true }).click()
|
|
const staleResponse = await stale
|
|
assert.equal(staleResponse.request().postDataJSON().params.listing_revision, first.listing_revision)
|
|
assert.equal(staleResponse.status(), 200)
|
|
const denied = await staleResponse.json()
|
|
assert.ok(denied.error && !denied.result, 'A changed listing must not return a partial page')
|
|
assert.equal(denied.error.code, -1)
|
|
assert.equal(denied.error.message, 'Cloud source is unavailable')
|
|
await expect(section.getByRole('alert')).toContainText('Cloud source is unavailable')
|
|
await expect(downloads).toHaveCount(0)
|
|
const refreshed = responseFor(1)
|
|
await section.getByRole('button', { name: 'Refresh from first page', exact: true }).click()
|
|
const fresh = await resultOf(await refreshed)
|
|
assert.notEqual(fresh.listing_revision, first.listing_revision)
|
|
assert.equal(fresh.items.length, 250)
|
|
assert.equal(fresh.next_page, 2)
|
|
const freshNames = fresh.items.map(item => item.name).filter(name => /^page-\d{4}\.txt$/.test(name))
|
|
await expect(downloads).toHaveCount(freshNames.length)
|
|
assert.deepEqual((await names()).sort(), freshNames.sort())
|
|
// Production deliberately exposes only the outer error context. Prove the
|
|
// revision cause with old/fresh/old requests, not an arbitrary generic error.
|
|
const params = { connection_id: connectionId, folder: [folder], page: 2 }
|
|
const rejectOld = () => assert.rejects(rpc('cloud.sources.browse', { ...params,
|
|
listing_revision: first.listing_revision }), { message: 'Cloud source is unavailable' })
|
|
await rejectOld()
|
|
const currentTail = await rpc('cloud.sources.browse', { ...params, listing_revision: fresh.listing_revision })
|
|
assert.equal(currentTail.listing_revision, fresh.listing_revision)
|
|
assert.equal(currentTail.items.length, 2)
|
|
assert.equal(currentTail.next_page, null)
|
|
const changedItems = [...fresh.items, ...currentTail.items]
|
|
assert.equal(new Set(changedItems.map(item => item.upstream_id)).size, COUNT + 1)
|
|
assert.deepEqual(changedItems.map(item => item.name).sort(), [...expected, 'changed.txt'].sort())
|
|
await rejectOld()
|
|
await section.getByRole('button', { name: 'All files', exact: true }).click()
|
|
await expect(section.getByRole('button', { name: 'Download contract.txt', exact: true })).toBeVisible()
|
|
console.log('PASS actual Nextcloud pagination: 250+1 unique items, later-page exact bytes, stable Previous, stale revision refusal and first-page refresh')
|
|
} finally { await api.dispose() }
|
|
}
|
|
module.exports = { seedPagination, exerciseNextcloudPagination, COUNT }
|