add more UTs, delete ununsed files
This commit is contained in:
+314
-52
@@ -1,46 +1,272 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
import {rest} from 'msw'
|
||||||
|
import {setupServer} from 'msw/node'
|
||||||
import {Input, InputParams} from '../src/input'
|
import {Input, InputParams} from '../src/input'
|
||||||
import {deleteVersions, finalIds} from '../src/delete'
|
import {deleteVersions, finalIds} from '../src/delete'
|
||||||
|
import {getMockedVersionsResponse} from './version/rest.mock'
|
||||||
|
import {RestEndpointMethodTypes} from '@octokit/plugin-rest-endpoint-methods/dist-types/generated/parameters-and-response-types'
|
||||||
|
|
||||||
describe.skip('index tests -- call graphql', () => {
|
type GetVersionsResponseData =
|
||||||
it('finalIds test -- get oldest version', done => {
|
RestEndpointMethodTypes['packages']['getAllPackageVersionsForPackageOwnedByUser']['response']['data']
|
||||||
const numVersions = 1
|
|
||||||
|
|
||||||
finalIds(getInput({numOldVersionsToDelete: numVersions})).subscribe(ids => {
|
const RATE_LIMIT = 99
|
||||||
expect(ids.length).toBe(numVersions)
|
|
||||||
done()
|
describe('index tests -- call rest', () => {
|
||||||
})
|
let server = setupServer()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
server = setupServer()
|
||||||
|
server.listen()
|
||||||
})
|
})
|
||||||
|
|
||||||
it.skip('finalIds test -- get oldest 3 versions', done => {
|
afterEach(() => {
|
||||||
const numVersions = 3
|
server.close()
|
||||||
finalIds(getInput({numOldVersionsToDelete: numVersions})).subscribe(ids => {
|
|
||||||
expect(ids.length).toBe(numVersions)
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it.skip('finalIds test -- get oldest 110 versions', done => {
|
it('finalIds test - supplied package version id', done => {
|
||||||
const numVersions = 110
|
const suppliedIds = ['123', '456', '789']
|
||||||
|
|
||||||
finalIds(getInput({numOldVersionsToDelete: numVersions})).subscribe(ids => {
|
|
||||||
expect(ids.length).toBe(99), async () => done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('finalIds test -- supplied package version id', done => {
|
|
||||||
const suppliedIds = [
|
|
||||||
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
||||||
'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB',
|
|
||||||
'CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC'
|
|
||||||
]
|
|
||||||
|
|
||||||
finalIds(getInput({packageVersionIds: suppliedIds})).subscribe(ids => {
|
finalIds(getInput({packageVersionIds: suppliedIds})).subscribe(ids => {
|
||||||
expect(ids).toBe(suppliedIds)
|
expect(ids).toBe(suppliedIds)
|
||||||
done()
|
done()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('deleteVersions test -- missing token', done => {
|
it('finalIDs test - success', done => {
|
||||||
|
const numVersions = 10
|
||||||
|
let apiCalled = 0
|
||||||
|
|
||||||
|
const versions = getMockedVersionsResponse(numVersions)
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
rest.get(
|
||||||
|
'https://api.github.com/users/test-owner/packages/npm/test-package/versions',
|
||||||
|
(req, res, ctx) => {
|
||||||
|
apiCalled++
|
||||||
|
return res(ctx.status(200), ctx.json(versions))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
finalIds(getInput()).subscribe(ids => {
|
||||||
|
expect(apiCalled).toBe(1)
|
||||||
|
expect(ids.length).toBe(numVersions)
|
||||||
|
for (let i = 0; i < numVersions; i++) {
|
||||||
|
expect(ids[i]).toBe(versions[i].id.toString())
|
||||||
|
}
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finalIDs test - success - pagination', done => {
|
||||||
|
const numVersions = RATE_LIMIT * 2
|
||||||
|
let apiCalled = 0
|
||||||
|
|
||||||
|
const versions = getMockedVersionsResponse(numVersions)
|
||||||
|
|
||||||
|
const firstPage = versions.slice(0, RATE_LIMIT)
|
||||||
|
const secondPage = versions.slice(RATE_LIMIT)
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
rest.get(
|
||||||
|
'https://api.github.com/users/test-owner/packages/npm/test-package/versions',
|
||||||
|
(req, res, ctx) => {
|
||||||
|
apiCalled++
|
||||||
|
const page = req.url.searchParams.get('page')
|
||||||
|
if (page === '1') {
|
||||||
|
return res(ctx.status(200), ctx.json(firstPage))
|
||||||
|
} else if (page === '2') {
|
||||||
|
return res(ctx.status(200), ctx.json(secondPage))
|
||||||
|
} else {
|
||||||
|
return res(ctx.status(200), ctx.json([]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
finalIds(getInput()).subscribe(ids => {
|
||||||
|
expect(apiCalled).toBe(3) // 2 full pages + 1 empty page
|
||||||
|
// never returns more than RATE_LIMIT versions
|
||||||
|
expect(ids.length).toBe(RATE_LIMIT)
|
||||||
|
for (let i = 0; i < RATE_LIMIT; i++) {
|
||||||
|
expect(ids[i]).toBe(versions[i].id.toString())
|
||||||
|
}
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finalIDs test - success - sorting accross pages', done => {
|
||||||
|
const numVersions = RATE_LIMIT * 2
|
||||||
|
let apiCalled = 0
|
||||||
|
|
||||||
|
// versions is in ascending order of created_at
|
||||||
|
const versions = getMockedVersionsResponse(numVersions)
|
||||||
|
|
||||||
|
// return newer versions on first page to test sorting
|
||||||
|
const firstPage = versions.slice(RATE_LIMIT).reverse()
|
||||||
|
const secondPage = versions.slice(0, RATE_LIMIT).reverse()
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
rest.get(
|
||||||
|
'https://api.github.com/users/test-owner/packages/npm/test-package/versions',
|
||||||
|
(req, res, ctx) => {
|
||||||
|
apiCalled++
|
||||||
|
const page = req.url.searchParams.get('page')
|
||||||
|
if (page === '1') {
|
||||||
|
return res(ctx.status(200), ctx.json(firstPage))
|
||||||
|
} else if (page === '2') {
|
||||||
|
return res(ctx.status(200), ctx.json(secondPage))
|
||||||
|
} else {
|
||||||
|
return res(ctx.status(200), ctx.json([]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
finalIds(getInput()).subscribe(ids => {
|
||||||
|
expect(apiCalled).toBe(3) // 2 full pages + 1 empty page
|
||||||
|
expect(ids.length).toBe(RATE_LIMIT)
|
||||||
|
for (let i = 0; i < RATE_LIMIT; i++) {
|
||||||
|
expect(ids[i]).toBe(versions[i].id.toString())
|
||||||
|
}
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finalIds test - do not delete more than numOldVersionsToDelete', done => {
|
||||||
|
const numVersions = 50
|
||||||
|
let apiCalled = 0
|
||||||
|
|
||||||
|
const versions = getMockedVersionsResponse(numVersions)
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
rest.get(
|
||||||
|
'https://api.github.com/users/test-owner/packages/npm/test-package/versions',
|
||||||
|
(req, res, ctx) => {
|
||||||
|
apiCalled++
|
||||||
|
return res(ctx.status(200), ctx.json(versions))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const numOldVersionsToDelete = 10
|
||||||
|
|
||||||
|
finalIds(getInput({numOldVersionsToDelete})).subscribe(ids => {
|
||||||
|
expect(apiCalled).toBe(1)
|
||||||
|
expect(ids.length).toBe(numOldVersionsToDelete)
|
||||||
|
for (let i = 0; i < numOldVersionsToDelete; i++) {
|
||||||
|
expect(ids[i]).toBe(versions[i].id.toString())
|
||||||
|
}
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finalIds test - keep minVersionsToKeep', done => {
|
||||||
|
const numVersions = 50
|
||||||
|
let apiCalled = 0
|
||||||
|
|
||||||
|
const versions = getMockedVersionsResponse(numVersions)
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
rest.get(
|
||||||
|
'https://api.github.com/users/test-owner/packages/npm/test-package/versions',
|
||||||
|
(req, res, ctx) => {
|
||||||
|
apiCalled++
|
||||||
|
return res(ctx.status(200), ctx.json(versions))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const minVersionsToKeep = 10
|
||||||
|
|
||||||
|
finalIds(getInput({minVersionsToKeep})).subscribe(ids => {
|
||||||
|
expect(apiCalled).toBe(1)
|
||||||
|
expect(ids.length).toBe(numVersions - minVersionsToKeep)
|
||||||
|
for (let i = 0; i < numVersions - minVersionsToKeep; i++) {
|
||||||
|
expect(ids[i]).toBe(versions[i].id.toString())
|
||||||
|
}
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finalIds test - delete only prerelease versions with minVersionsToKeep', done => {
|
||||||
|
const numVersions = 50
|
||||||
|
let apiCalled = 0
|
||||||
|
|
||||||
|
const versions = getMockedVersionsResponse(numVersions)
|
||||||
|
// make half versions prerelease
|
||||||
|
for (let i = 0; i < numVersions; i++) {
|
||||||
|
if (i % 2 === 0) {
|
||||||
|
versions[i].name += '-alpha'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
rest.get(
|
||||||
|
'https://api.github.com/users/test-owner/packages/npm/test-package/versions',
|
||||||
|
(req, res, ctx) => {
|
||||||
|
apiCalled++
|
||||||
|
return res(ctx.status(200), ctx.json(versions))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const toDelete = numVersions / 2 - 10
|
||||||
|
|
||||||
|
finalIds(
|
||||||
|
getInput({
|
||||||
|
ignoreVersions: RegExp('^(0|[1-9]\\d*)((\\.(0|[1-9]\\d*))*)$'),
|
||||||
|
minVersionsToKeep: 10
|
||||||
|
})
|
||||||
|
).subscribe(ids => {
|
||||||
|
expect(apiCalled).toBe(1)
|
||||||
|
expect(ids.length).toBe(toDelete)
|
||||||
|
for (let i = 0; i < toDelete; i++) {
|
||||||
|
expect(ids[i]).toBe(versions[i * 2].id.toString())
|
||||||
|
}
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finalIds test - no versions deleted if API error even once', done => {
|
||||||
|
const numVersions = RATE_LIMIT * 2
|
||||||
|
let apiCalled = 0
|
||||||
|
|
||||||
|
const versions = getMockedVersionsResponse(numVersions)
|
||||||
|
|
||||||
|
const firstPage = versions.slice(0, RATE_LIMIT)
|
||||||
|
const secondPage = versions.slice(RATE_LIMIT)
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
rest.get(
|
||||||
|
'https://api.github.com/users/test-owner/packages/npm/test-package/versions',
|
||||||
|
(req, res, ctx) => {
|
||||||
|
apiCalled++
|
||||||
|
const page = req.url.searchParams.get('page')
|
||||||
|
if (page === '1') {
|
||||||
|
return res(ctx.status(200), ctx.json(firstPage))
|
||||||
|
} else if (page === '2') {
|
||||||
|
return res(ctx.status(500), ctx.json([]))
|
||||||
|
} else {
|
||||||
|
return res(ctx.status(200), ctx.json([]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
finalIds(getInput()).subscribe(
|
||||||
|
() => {
|
||||||
|
done.fail('should not complete')
|
||||||
|
},
|
||||||
|
err => {
|
||||||
|
expect(apiCalled).toBe(2) // 1 full page + 1 error page
|
||||||
|
expect(err).toBeTruthy()
|
||||||
|
expect(err).toContain('get versions API failed.')
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteVersions test - missing token', done => {
|
||||||
deleteVersions(getInput({token: ''})).subscribe({
|
deleteVersions(getInput({token: ''})).subscribe({
|
||||||
error: err => {
|
error: err => {
|
||||||
expect(err).toBeTruthy()
|
expect(err).toBeTruthy()
|
||||||
@@ -50,7 +276,7 @@ describe.skip('index tests -- call graphql', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('deleteVersions test -- missing packageName', done => {
|
it('deleteVersions test - missing packageName', done => {
|
||||||
deleteVersions(getInput({packageName: ''})).subscribe({
|
deleteVersions(getInput({packageName: ''})).subscribe({
|
||||||
error: err => {
|
error: err => {
|
||||||
expect(err).toBeTruthy()
|
expect(err).toBeTruthy()
|
||||||
@@ -60,40 +286,76 @@ describe.skip('index tests -- call graphql', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it.skip('deleteVersions test -- delete oldest version', done => {
|
it('deleteVersions test - missing packageType', done => {
|
||||||
deleteVersions(getInput({numOldVersionsToDelete: 1})).subscribe(
|
deleteVersions(getInput({packageType: ''})).subscribe({
|
||||||
isSuccess => {
|
error: err => {
|
||||||
expect(isSuccess)
|
expect(err).toBeTruthy()
|
||||||
|
done()
|
||||||
},
|
},
|
||||||
async () => done()
|
complete: async () => done.fail('no error thrown')
|
||||||
)
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it.skip('deleteVersions test -- delete 3 oldest versions', done => {
|
it('deleteVersions test - zero numOldVersionsToDelete', done => {
|
||||||
deleteVersions(getInput({numOldVersionsToDelete: 3})).subscribe(
|
deleteVersions(getInput({numOldVersionsToDelete: 0})).subscribe(result => {
|
||||||
isSuccess => {
|
expect(result).toBe(true)
|
||||||
expect(isSuccess)
|
done()
|
||||||
},
|
})
|
||||||
async () => done()
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it.skip('deleteVersions test -- keep 5 versions', done => {
|
it('deleteVersions test - success complete flow', done => {
|
||||||
deleteVersions(getInput({minVersionsToKeep: 100})).subscribe(isSuccess => {
|
const numVersions = 10
|
||||||
expect(isSuccess).toBe(true)
|
let getApiCalled = 0
|
||||||
}),
|
let deleteApiCalled = 0
|
||||||
async () => done()
|
|
||||||
|
const versions = getMockedVersionsResponse(numVersions)
|
||||||
|
const versionsDeleted: string[] = []
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
rest.get(
|
||||||
|
'https://api.github.com/users/test-owner/packages/npm/test-package/versions',
|
||||||
|
(req, res, ctx) => {
|
||||||
|
getApiCalled++
|
||||||
|
return res(ctx.status(200), ctx.json(versions))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
rest.delete(
|
||||||
|
'https://api.github.com/users/test-owner/packages/npm/test-package/versions/:versionId',
|
||||||
|
(req, res, ctx) => {
|
||||||
|
deleteApiCalled++
|
||||||
|
versionsDeleted.push(req.params.versionId as string)
|
||||||
|
return res(ctx.status(204))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
deleteVersions(getInput())
|
||||||
|
.subscribe(result => {
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
.add(() => {
|
||||||
|
expect(getApiCalled).toBe(1)
|
||||||
|
expect(deleteApiCalled).toBe(numVersions)
|
||||||
|
for (let i = 0; i < numVersions; i++) {
|
||||||
|
expect(versionsDeleted[i]).toBe(versions[i].id.toString())
|
||||||
|
}
|
||||||
|
done()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const defaultInput: InputParams = {
|
const defaultInput: InputParams = {
|
||||||
packageVersionIds: [],
|
packageVersionIds: [],
|
||||||
owner: 'namratajha',
|
owner: 'test-owner',
|
||||||
packageName: 'only-pkg',
|
packageName: 'test-package',
|
||||||
numOldVersionsToDelete: 1,
|
packageType: 'npm',
|
||||||
|
numOldVersionsToDelete: RATE_LIMIT,
|
||||||
minVersionsToKeep: -1,
|
minVersionsToKeep: -1,
|
||||||
ignoreVersions: RegExp('^$'),
|
ignoreVersions: RegExp('^$'),
|
||||||
token: process.env.GITHUB_TOKEN as string
|
token: 'test-token'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInput(params?: InputParams): Input {
|
function getInput(params?: InputParams): Input {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
||||||
import {rest} from 'msw'
|
import {rest} from 'msw'
|
||||||
import {setupServer} from 'msw/node'
|
import {setupServer} from 'msw/node'
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
||||||
import {RestEndpointMethodTypes} from '@octokit/plugin-rest-endpoint-methods/dist-types/generated/parameters-and-response-types'
|
import {RestEndpointMethodTypes} from '@octokit/plugin-rest-endpoint-methods/dist-types/generated/parameters-and-response-types'
|
||||||
|
|
||||||
type GetVersionsResponseData = RestEndpointMethodTypes['packages']['getAllPackageVersionsForPackageOwnedByUser']['response']['data']
|
type GetVersionsResponseData =
|
||||||
|
RestEndpointMethodTypes['packages']['getAllPackageVersionsForPackageOwnedByUser']['response']['data']
|
||||||
|
|
||||||
export function getMockedVersionsResponse(
|
export function getMockedVersionsResponse(
|
||||||
numVersions: number
|
numVersions: number,
|
||||||
|
offset = 0
|
||||||
): GetVersionsResponseData {
|
): GetVersionsResponseData {
|
||||||
const versions: GetVersionsResponseData = []
|
const versions: GetVersionsResponseData = []
|
||||||
for (let i = 1; i <= numVersions; ++i) {
|
for (let i = 1 + offset; i <= numVersions + offset; ++i) {
|
||||||
|
const created_at = new Date()
|
||||||
|
created_at.setUTCFullYear(2000 + Number(i), 1, 1)
|
||||||
versions.push({
|
versions.push({
|
||||||
id: i,
|
id: i,
|
||||||
name: `v${i}.0.0`,
|
name: `${i}.0.0`,
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
url: '',
|
url: '',
|
||||||
|
created_at: created_at.toUTCString(),
|
||||||
package_html_url: '',
|
package_html_url: '',
|
||||||
updated_at: ''
|
updated_at: ''
|
||||||
})
|
})
|
||||||
|
|||||||
Vendored
+160
-157
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable i18n-text/no-en */
|
/* eslint-disable i18n-text/no-en */
|
||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
||||||
import {Input} from './input'
|
import {Input} from './input'
|
||||||
import {EMPTY, Observable, of, throwError} from 'rxjs'
|
import {EMPTY, Observable, of, throwError} from 'rxjs'
|
||||||
import {reduce, concatMap, map, expand, tap} from 'rxjs/operators'
|
import {reduce, concatMap, map, expand, tap} from 'rxjs/operators'
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import * as github from '@actions/github'
|
|
||||||
|
|
||||||
// Centralize all Octokit references by re-exporting
|
|
||||||
export {Octokit} from '@octokit/rest'
|
|
||||||
|
|
||||||
export interface OctokitOptions {
|
|
||||||
baseUrl?: string
|
|
||||||
userAgent?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getOctokit(
|
|
||||||
authToken: string,
|
|
||||||
opts: OctokitOptions
|
|
||||||
): github.GitHub {
|
|
||||||
const options: OctokitOptions = {
|
|
||||||
baseUrl: 'https://api.github.com'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.userAgent) {
|
|
||||||
options.userAgent = opts.userAgent
|
|
||||||
}
|
|
||||||
|
|
||||||
return new github.GitHub(authToken, options)
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import {GitHub} from '@actions/github'
|
|
||||||
import {GraphQlQueryResponseData} from '@octokit/graphql/dist-types/types'
|
|
||||||
import {RequestParameters} from '@octokit/types/dist-types/RequestParameters'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends a GraphQL query request based on endpoint options
|
|
||||||
*
|
|
||||||
* @param {string} token Auth token
|
|
||||||
* @param {string} query GraphQL query. Example: `'query { viewer { login } }'`.
|
|
||||||
* @param {object} parameters URL, query or body parameters, as well as `headers`, `mediaType.{format|previews}`, `request`, or `baseUrl`.
|
|
||||||
*/
|
|
||||||
export async function graphql(
|
|
||||||
token: string,
|
|
||||||
query: string,
|
|
||||||
parameters: RequestParameters
|
|
||||||
): Promise<GraphQlQueryResponseData> {
|
|
||||||
const github = new GitHub(token)
|
|
||||||
return await github.graphql(query, parameters)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user