diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index 72e0e0d91d..cd16fa91a6 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -53,7 +53,7 @@ Before running the function, `invoke()` also acquires a Studio Web license for t | `getByName()` | `OR.Buckets` or `OR.Buckets.Read` | | `getFileMetaData()` | `OR.Buckets` or `OR.Buckets.Read` | | `getReadUri()` | `OR.Buckets` or `OR.Buckets.Read` | -| `uploadFile()` | `OR.Buckets` | +| `uploadFile()` | `OR.Buckets` or `OR.Buckets.Write` | | `deleteFile()` | `OR.Buckets` or `OR.Buckets.Write` | | `getFiles()` | `OR.Buckets` or `OR.Buckets.Read` | diff --git a/src/models/orchestrator/buckets.models.ts b/src/models/orchestrator/buckets.models.ts index 83ed1536fd..e87c3b06a8 100644 --- a/src/models/orchestrator/buckets.models.ts +++ b/src/models/orchestrator/buckets.models.ts @@ -1,4 +1,4 @@ -import { BucketGetAllOptions, BucketGetByIdOptions, BucketGetByNameOptions, BucketGetResponse, BucketGetFileMetaDataWithPaginationOptions, BucketGetReadUriOptions, BucketGetReadUriRequestOptions, BucketGetUriResponse, BucketUploadFileOptions, BucketUploadFileRequestOptions, BucketUploadResponse, BlobItem, BucketGetFilesOptions, BucketFile, BucketDeleteFileOptions } from './buckets.types'; +import { BucketGetAllOptions, BucketGetByIdOptions, BucketGetByNameOptions, BucketGetResponse, BucketGetFileMetaDataWithPaginationOptions, BucketGetReadUriOptions, BucketGetReadUriRequestOptions, BucketGetUriResponse, BucketRef, BucketUploadFileOptions, BucketUploadFileRequestOptions, BucketUploadResponse, BlobItem, BucketGetFilesOptions, BucketFile, BucketDeleteFileOptions } from './buckets.types'; import { PaginatedResponse, NonPaginatedResponse, HasPaginationOptions } from '../../utils/pagination'; /** @@ -111,33 +111,49 @@ export interface BucketServiceModel { * - A NonPaginatedResponse with items array (when no pagination parameters are provided) * - A PaginatedResponse with navigation cursors (when any pagination parameter is provided) * - * @param bucketId - The ID of the bucket to get file metadata from + * @param bucketRef - Bucket ref (`{ id }` or `{ name }`). `{ name }` triggers an internal + * name lookup where runtime resource overrides may redirect the target across folders. * @param options - Folder scoping (`folderId` / `folderKey` / `folderPath`) and optional parameters for filtering and pagination * @returns Promise resolving to either an array of files metadata NonPaginatedResponse or a PaginatedResponse when pagination options are used. * {@link BlobItem} * @example * ```typescript - * // By folder ID - * const fileMetadata = await buckets.getFileMetaData(, { folderId: }); - * - * // By folder key (GUID) - * await buckets.getFileMetaData(, { folderKey: '5f6dadf1-3677-49dc-8aca-c2999dd4b3ba' }); + * // By bucket id + * const fileMetadata = await buckets.getFileMetaData({ id: }, { folderId: }); * - * // By folder path - * await buckets.getFileMetaData(, { folderPath: 'Shared/Finance' }); + * // By bucket name (folder scoping applies to both the name lookup and the meta-data read) + * await buckets.getFileMetaData({ name: 'InvoicesBucket' }, { folderPath: 'Shared/Finance' }); * * // Filter by prefix - * await buckets.getFileMetaData(, { folderId: , prefix: '/folder1' }); + * await buckets.getFileMetaData({ id: }, { folderId: , prefix: '/folder1' }); * * // First page with pagination - * const page1 = await buckets.getFileMetaData(, { folderId: , pageSize: 10 }); + * const page1 = await buckets.getFileMetaData({ id: }, { folderId: , pageSize: 10 }); * * // Navigate using cursor * if (page1.hasNextPage) { - * const page2 = await buckets.getFileMetaData(, { folderId: , cursor: page1.nextCursor }); + * const page2 = await buckets.getFileMetaData({ id: }, { folderId: , cursor: page1.nextCursor }); * } * ``` */ + getFileMetaData( + bucketRef: BucketRef, + options?: T, + ): Promise< + T extends HasPaginationOptions + ? PaginatedResponse + : NonPaginatedResponse + >; + /** + * Gets metadata for files in a bucket — numeric bucket id form. + * + * @deprecated Use the ref-based form: `getFileMetaData({ id: bucketId }, options?)`. See {@link BucketRef}. + * + * @param bucketId - The ID of the bucket to get file metadata from + * @param options - Folder scoping (`folderId` / `folderKey` / `folderPath`) and optional parameters for filtering and pagination + * @returns Promise resolving to either an array of files metadata NonPaginatedResponse or a PaginatedResponse when pagination options are used. + * {@link BlobItem} + */ getFileMetaData( bucketId: number, options?: T, @@ -149,7 +165,7 @@ export interface BucketServiceModel { /** * Gets metadata for files in a bucket — positional `folderId` form. * - * @deprecated Use the options-object form: `getFileMetaData(bucketId, { folderId })`. See {@link BucketGetFileMetaDataWithPaginationOptions} for the supported options. + * @deprecated Use the ref-based form: `getFileMetaData({ id: bucketId }, { folderId })`. See {@link BucketRef}. * * @param bucketId - The ID of the bucket to get file metadata from * @param folderId - Required folder ID (numeric) @@ -173,23 +189,37 @@ export interface BucketServiceModel { * Folder context can be supplied as `folderId`, `folderKey`, or `folderPath` * in the options. * - * @param bucketId - The ID of the bucket + * @param bucketRef - Bucket ref (`{ id }` or `{ name }`). `{ name }` triggers an internal + * name lookup where runtime resource overrides may redirect the target across folders. * @param path - The full path to the file * @param options - Folder scoping (`folderId` / `folderKey` / `folderPath`) and optional `expiryInMinutes` * @returns Promise resolving to blob file access information * {@link BucketGetUriResponse} * @example * ```typescript - * // By folder ID - * await buckets.getReadUri(, '/folder/file.pdf', { folderId: }); + * // By bucket id + * await buckets.getReadUri({ id: }, '/folder/file.pdf', { folderId: }); * - * // By folder key (GUID) - * await buckets.getReadUri(, '/folder/file.pdf', { folderKey: '5f6dadf1-3677-49dc-8aca-c2999dd4b3ba' }); - * - * // By folder path - * await buckets.getReadUri(, '/folder/file.pdf', { folderPath: 'Shared/Finance' }); + * // By bucket name (folder scoping applies to both the name lookup and the read) + * await buckets.getReadUri({ name: 'MyBucket' }, '/folder/file.pdf', { folderPath: 'Shared/Finance' }); * ``` */ + getReadUri( + bucketRef: BucketRef, + path: string, + options?: BucketGetReadUriRequestOptions, + ): Promise; + /** + * Gets a direct download URL for a file in the bucket — numeric bucket id form. + * + * @deprecated Use the ref-based form: `getReadUri({ id: bucketId }, path, options?)`. See {@link BucketRef}. + * + * @param bucketId - The ID of the bucket + * @param path - The full path to the file + * @param options - Folder scoping (`folderId` / `folderKey` / `folderPath`) and optional `expiryInMinutes` + * @returns Promise resolving to blob file access information + * {@link BucketGetUriResponse} + */ getReadUri( bucketId: number, path: string, @@ -198,7 +228,7 @@ export interface BucketServiceModel { /** * Gets a direct download URL for a file in the bucket — options-only form. * - * @deprecated Use the positional form: `getReadUri(bucketId, path, options?)`. See {@link BucketGetReadUriRequestOptions} for the supported options. + * @deprecated Use the ref-based form: `getReadUri({ id: bucketId }, path, options?)`. See {@link BucketRef}. * * @param options - Contains bucketId, folder scoping (`folderId` / `folderKey` / `folderPath`), file path and optional expiry time * @returns Promise resolving to blob file access information @@ -212,7 +242,8 @@ export interface BucketServiceModel { * Folder context can be supplied as `folderId`, `folderKey`, or `folderPath` * in the options. * - * @param bucketId - The ID of the bucket to upload to + * @param bucketRef - Bucket ref (`{ id }` or `{ name }`). `{ name }` triggers an internal + * name lookup where runtime resource overrides may redirect the target across folders. * @param path - Path where the file should be stored in the bucket * @param content - File content to upload * @param options - Folder scoping (`folderId` / `folderKey` / `folderPath`) @@ -220,21 +251,36 @@ export interface BucketServiceModel { * {@link BucketUploadResponse} * @example * ```typescript - * // By folder ID + * // By bucket id * const file = new File(['file content'], 'example.txt'); - * await buckets.uploadFile(, '/folder/example.txt', file, { folderId: }); + * await buckets.uploadFile({ id: }, '/folder/example.txt', file, { folderId: }); * - * // By folder key (GUID) - * await buckets.uploadFile(, '/folder/example.txt', file, { folderKey: '5f6dadf1-3677-49dc-8aca-c2999dd4b3ba' }); - * - * // By folder path - * await buckets.uploadFile(, '/folder/example.txt', file, { folderPath: 'Shared/Finance' }); + * // By bucket name (folder scoping applies to both the name lookup and the upload) + * await buckets.uploadFile({ name: 'MyBucket' }, '/folder/example.txt', file, { folderPath: 'Shared/Finance' }); * * // In Node env with Uint8Array or Buffer * const content = new TextEncoder().encode('file content'); - * await buckets.uploadFile(, '/folder/example.txt', content, { folderId: }); + * await buckets.uploadFile({ id: }, '/folder/example.txt', content, { folderId: }); * ``` */ + uploadFile( + bucketRef: BucketRef, + path: string, + content: Blob | Uint8Array | File, + options?: BucketUploadFileRequestOptions, + ): Promise; + /** + * Uploads a file to a bucket — numeric bucket id form. + * + * @deprecated Use the ref-based form: `uploadFile({ id: bucketId }, path, content, options?)`. See {@link BucketRef}. + * + * @param bucketId - The ID of the bucket + * @param path - Path where the file should be stored in the bucket + * @param content - File content to upload + * @param options - Folder scoping (`folderId` / `folderKey` / `folderPath`) + * @returns Promise resolving bucket upload response + * {@link BucketUploadResponse} + */ uploadFile( bucketId: number, path: string, @@ -244,7 +290,7 @@ export interface BucketServiceModel { /** * Uploads a file to a bucket — options-only form. * - * @deprecated Use the positional form: `uploadFile(bucketId, path, content, options?)`. See {@link BucketUploadFileRequestOptions} for the supported options. + * @deprecated Use the ref-based form: `uploadFile({ id: bucketId }, path, content, options?)`. See {@link BucketRef}. * * @param options - Options for file upload including bucket ID, folder scoping (`folderId` / `folderKey` / `folderPath`), path, and content * @returns Promise resolving bucket upload response @@ -255,16 +301,31 @@ export interface BucketServiceModel { /** * Deletes a file from a bucket * - * @param bucketId - The ID of the bucket + * @param bucketRef - Bucket ref (`{ id }` or `{ name }`). `{ name }` triggers an internal + * name lookup where runtime resource overrides may redirect the target across folders. * @param path - The full path to the file to delete * @param options - Folder scoping (`folderId` / `folderKey` / `folderPath`) * @returns Promise resolving when the file is deleted * @example * ```typescript - * // Delete a file from a bucket - * await buckets.deleteFile(, '/folder/file.pdf', { folderId: }); + * // By bucket id + * await buckets.deleteFile({ id: }, '/folder/file.pdf', { folderId: }); + * + * // By bucket name + * await buckets.deleteFile({ name: 'MyBucket' }, '/folder/file.pdf', { folderPath: 'Shared/Finance' }); * ``` */ + deleteFile(bucketRef: BucketRef, path: string, options?: BucketDeleteFileOptions): Promise; + /** + * Deletes a file from a bucket — numeric bucket id form. + * + * @deprecated Use the ref-based form: `deleteFile({ id: bucketId }, path, options?)`. See {@link BucketRef}. + * + * @param bucketId - The ID of the bucket + * @param path - The full path to the file to delete + * @param options - Folder scoping (`folderId` / `folderKey` / `folderPath`) + * @returns Promise resolving when the file is deleted + */ deleteFile(bucketId: number, path: string, options?: BucketDeleteFileOptions): Promise; /** @@ -278,38 +339,61 @@ export interface BucketServiceModel { * - A NonPaginatedResponse with items array (when no pagination parameters are provided) * - A PaginatedResponse with navigation cursors (when any pagination parameter is provided) * - * @param bucketId - The ID of the bucket + * @param bucketRef - Bucket ref (`{ id }` or `{ name }`). `{ name }` triggers an internal + * name lookup where runtime resource overrides may redirect the target across folders. * @param options - Folder scoping (`folderId` / `folderKey` / `folderPath`) and optional parameters for regex filtering, query options, and pagination * {@link BucketGetFilesOptions} * @returns Promise resolving to either an array of files NonPaginatedResponse or a PaginatedResponse when pagination options are used. * {@link BucketFile} * @example * ```typescript - * // List all files in the bucket - * const files = await buckets.getFiles(, { folderId: }); + * // By bucket id + * const files = await buckets.getFiles({ id: }, { folderId: }); + * + * // By bucket name (folder scoping applies to both the name lookup and the listing) + * const filesByName = await buckets.getFiles({ name: 'MyBucket' }, { folderPath: 'Shared/Finance' }); * * // Filter by regex pattern - * const pdfs = await buckets.getFiles(, { + * const pdfs = await buckets.getFiles({ id: }, { * folderId: , * fileNameRegex: '.*\\.pdf$' * }); * * // First page with pagination - * const page1 = await buckets.getFiles(, { folderId: , pageSize: 10 }); + * const page1 = await buckets.getFiles({ id: }, { folderId: , pageSize: 10 }); * * // Navigate using cursor * if (page1.hasNextPage) { - * const page2 = await buckets.getFiles(, { folderId: , cursor: page1.nextCursor }); + * const page2 = await buckets.getFiles({ id: }, { folderId: , cursor: page1.nextCursor }); * } * * // Jump to specific page - * const page5 = await buckets.getFiles(, { + * const page5 = await buckets.getFiles({ id: }, { * folderId: , * jumpToPage: 5, * pageSize: 10 * }); * ``` */ + getFiles( + bucketRef: BucketRef, + options?: T + ): Promise< + T extends HasPaginationOptions + ? PaginatedResponse + : NonPaginatedResponse + >; + /** + * Lists all files in a bucket — numeric bucket id form. + * + * @deprecated Use the ref-based form: `getFiles({ id: bucketId }, options?)`. See {@link BucketRef}. + * + * @param bucketId - The ID of the bucket + * @param options - Folder scoping (`folderId` / `folderKey` / `folderPath`) and optional parameters for regex filtering, query options, and pagination + * {@link BucketGetFilesOptions} + * @returns Promise resolving to either an array of files NonPaginatedResponse or a PaginatedResponse when pagination options are used. + * {@link BucketFile} + */ getFiles( bucketId: number, options?: T diff --git a/src/models/orchestrator/buckets.types.ts b/src/models/orchestrator/buckets.types.ts index b2f0528665..9d13ba66e8 100644 --- a/src/models/orchestrator/buckets.types.ts +++ b/src/models/orchestrator/buckets.types.ts @@ -34,6 +34,20 @@ export interface BucketGetByIdOptions extends BaseOptions {} */ export interface BucketGetByNameOptions extends FolderScopedOptions {} +/** + * Selects a bucket by exactly one identifier — `{ id }` numeric or `{ name }` + * (folder-scoped). Used by the ref-based file-op signatures on + * {@link BucketServiceModel} (`uploadFile`, `deleteFile`, `getReadUri`, + * `getFiles`, `getFileMetaData`). `{ name }` triggers an internal + * folder-scoped lookup so runtime overrides apply; `{ id }` skips the lookup. + * + * Narrower than the generic {@link ResourceRef} — buckets don't have a public GUID key + * on their operational routes, so `{ key }` is intentionally not part of this union. + */ +export type BucketRef = + | { id: number; name?: never; key?: never } + | { name: string; id?: never; key?: never }; + /** * Maps header names to their values * diff --git a/src/services/orchestrator/buckets/buckets.ts b/src/services/orchestrator/buckets/buckets.ts index e18dff5dc2..17fa7e4162 100644 --- a/src/services/orchestrator/buckets/buckets.ts +++ b/src/services/orchestrator/buckets/buckets.ts @@ -9,6 +9,7 @@ import { BucketGetReadUriOptions, BucketGetReadUriRequestOptions, BucketGetFileMetaDataWithPaginationOptions, + BucketRef, BucketUploadFileOptions, BucketUploadFileRequestOptions, BucketUploadResponse, @@ -18,6 +19,7 @@ import { BucketFile, BucketDeleteFileOptions } from '../../../models/orchestrator/buckets.types'; +import { FolderScopingOnly } from '../../../models/common/types'; import { BucketServiceModel } from '../../../models/orchestrator/buckets.models'; import { pascalToCamelCaseKeys, addPrefixToKeys, transformData, transformOptions, arrayDictionaryToRecord } from '../../../utils/transform'; import { filterUndefined } from '../../../utils/object'; @@ -32,6 +34,7 @@ import { PaginatedResponse, NonPaginatedResponse, HasPaginationOptions } from '. import { PaginationHelpers } from '../../../utils/pagination/helpers'; import { PaginationType } from '../../../utils/pagination/internal-types'; import { track } from '../../../core/telemetry'; +import { resolveRefToId, type ResolvedRef } from '../../../utils/validation/resolve-ref'; export class BucketService extends FolderScopedService implements BucketServiceModel { @track('Buckets.GetById') @@ -104,6 +107,14 @@ export class BucketService extends FolderScopedService implements BucketServiceM }, options) as any; } + getFileMetaData( + bucketRef: BucketRef, + options?: T, + ): Promise< + T extends HasPaginationOptions + ? PaginatedResponse + : NonPaginatedResponse + >; getFileMetaData( bucketId: number, options?: T, @@ -123,7 +134,7 @@ export class BucketService extends FolderScopedService implements BucketServiceM >; @track('Buckets.GetFileMetaData') async getFileMetaData( - bucketId: number, + bucketIdOrRef: number | BucketRef, optionsOrFolderId?: T | number, legacyOptions?: T, ): Promise< @@ -131,10 +142,6 @@ export class BucketService extends FolderScopedService implements BucketServiceM ? PaginatedResponse : NonPaginatedResponse > { - if (!bucketId) { - throw new ValidationError({ message: 'bucketId is required for getFileMetaData' }); - } - // Normalize the two overload forms into a single internal shape. let folderId: number | undefined; let folderKey: string | undefined; @@ -146,15 +153,25 @@ export class BucketService extends FolderScopedService implements BucketServiceM folderId = optionsOrFolderId; restOptions = (legacyOptions ?? {}) as Omit; } else { - // Preferred form: getFileMetaData(bucketId, options?) + // Preferred form: getFileMetaData(bucketRef, options?) const opts = optionsOrFolderId ?? ({} as T); ({ folderId, folderKey, folderPath, ...restOptions } = opts); } + const { id: bucketId, effectiveFolder } = await this.resolveBucketRef( + bucketIdOrRef, + { folderId, folderKey, folderPath }, + 'Buckets.getFileMetaData', + ); + + if (bucketId <= 0) { + throw new ValidationError({ message: 'Buckets.getFileMetaData: bucketId must be a positive number.' }); + } + const headers = resolveFolderHeaders({ - folderId, - folderKey, - folderPath, + folderId: effectiveFolder.folderId ?? folderId, + folderKey: effectiveFolder.folderKey ?? folderKey, + folderPath: effectiveFolder.folderPath ?? folderPath, resourceType: 'Buckets.getFileMetaData', fallbackFolderKey: this.config.folderKey, }); @@ -185,6 +202,12 @@ export class BucketService extends FolderScopedService implements BucketServiceM }, apiRestOptions) as any; } + uploadFile( + bucketRef: BucketRef, + path: string, + content: Blob | Uint8Array | File, + options?: BucketUploadFileRequestOptions, + ): Promise; uploadFile( bucketId: number, path: string, @@ -194,35 +217,42 @@ export class BucketService extends FolderScopedService implements BucketServiceM uploadFile(options: BucketUploadFileOptions): Promise; @track('Buckets.UploadFile') async uploadFile( - bucketIdOrOptions: number | BucketUploadFileOptions, + firstArg: number | BucketRef | BucketUploadFileOptions, path?: string, content?: Blob | Uint8Array | File, options?: BucketUploadFileRequestOptions, ): Promise { - // Normalize the two overload forms into a single internal shape. - let bucketId: number; + // Discriminate the three overload forms. + let bucketIdOrRef: number | BucketRef; let resolvedPath: string; let resolvedContent: Blob | Uint8Array | File; let folderId: number | undefined; let folderKey: string | undefined; let folderPath: string | undefined; - if (bucketIdOrOptions !== null && typeof bucketIdOrOptions === 'object') { + const isDeprecatedOptionsForm = + firstArg !== null && + typeof firstArg === 'object' && + 'bucketId' in (firstArg as Record); + + if (isDeprecatedOptionsForm) { // Deprecated options-only form: uploadFile({ bucketId, path, content, ... }) - ({ bucketId, path: resolvedPath, content: resolvedContent, folderId, folderKey, folderPath } = bucketIdOrOptions); + const opts = firstArg as BucketUploadFileOptions; + bucketIdOrRef = opts.bucketId; + resolvedPath = opts.path; + resolvedContent = opts.content; + folderId = opts.folderId; + folderKey = opts.folderKey; + folderPath = opts.folderPath; } else { - // Preferred positional form: uploadFile(bucketId, path, content, options?) - bucketId = bucketIdOrOptions; + // Positional form: uploadFile(bucketRef | bucketId, path, content, options?) + bucketIdOrRef = firstArg as number | BucketRef; resolvedPath = path as string; resolvedContent = content as Blob | Uint8Array | File; const opts = options ?? ({} as BucketUploadFileRequestOptions); ({ folderId, folderKey, folderPath } = opts); } - if (!bucketId) { - throw new ValidationError({ message: 'bucketId is required for uploadFile' }); - } - if (!resolvedPath) { throw new ValidationError({ message: 'path is required for uploadFile' }); } @@ -231,10 +261,20 @@ export class BucketService extends FolderScopedService implements BucketServiceM throw new ValidationError({ message: 'content is required for uploadFile' }); } + const { id: bucketId, effectiveFolder } = await this.resolveBucketRef( + bucketIdOrRef, + { folderId, folderKey, folderPath }, + 'Buckets.uploadFile', + ); + + if (bucketId <= 0) { + throw new ValidationError({ message: 'Buckets.uploadFile: bucketId must be a positive number.' }); + } + const headers = resolveFolderHeaders({ - folderId, - folderKey, - folderPath, + folderId: effectiveFolder.folderId ?? folderId, + folderKey: effectiveFolder.folderKey ?? folderKey, + folderPath: effectiveFolder.folderPath ?? folderPath, resourceType: 'Buckets.uploadFile', fallbackFolderKey: this.config.folderKey, }); @@ -254,6 +294,11 @@ export class BucketService extends FolderScopedService implements BucketServiceM }; } + getReadUri( + bucketRef: BucketRef, + path: string, + options?: BucketGetReadUriRequestOptions, + ): Promise; getReadUri( bucketId: number, path: string, @@ -262,12 +307,12 @@ export class BucketService extends FolderScopedService implements BucketServiceM getReadUri(options: BucketGetReadUriOptions): Promise; @track('Buckets.GetReadUri') async getReadUri( - bucketIdOrOptions: number | BucketGetReadUriOptions, + firstArg: number | BucketRef | BucketGetReadUriOptions, path?: string, options?: BucketGetReadUriRequestOptions, ): Promise { - // Normalize the two overload forms into a single internal shape. - let bucketId: number; + // Discriminate the three overload forms. + let bucketIdOrRef: number | BucketRef; let resolvedPath: string; let folderId: number | undefined; let folderKey: string | undefined; @@ -275,10 +320,16 @@ export class BucketService extends FolderScopedService implements BucketServiceM let expiryInMinutes: number | undefined; let restOptions: Record; - if (bucketIdOrOptions !== null && typeof bucketIdOrOptions === 'object') { + const isDeprecatedOptionsForm = + firstArg !== null && + typeof firstArg === 'object' && + 'bucketId' in (firstArg as Record); + + if (isDeprecatedOptionsForm) { // Deprecated options-only form: getReadUri({ bucketId, path, ... }) - const { bucketId: bid, path: p, expiryInMinutes: e, folderId: fid, folderKey: fkey, folderPath: fpath, ...rest } = bucketIdOrOptions; - bucketId = bid; + const opts = firstArg as BucketGetReadUriOptions; + const { bucketId: bid, path: p, expiryInMinutes: e, folderId: fid, folderKey: fkey, folderPath: fpath, ...rest } = opts; + bucketIdOrRef = bid; resolvedPath = p; expiryInMinutes = e; folderId = fid; @@ -286,17 +337,31 @@ export class BucketService extends FolderScopedService implements BucketServiceM folderPath = fpath; restOptions = rest; } else { - // Preferred positional form: getReadUri(bucketId, path, options?) - bucketId = bucketIdOrOptions; + // Positional form: getReadUri(bucketRef | bucketId, path, options?) + bucketIdOrRef = firstArg as number | BucketRef; resolvedPath = path as string; const opts = options ?? ({} as BucketGetReadUriRequestOptions); ({ expiryInMinutes, folderId, folderKey, folderPath, ...restOptions } = opts); } + const { id: bucketId, effectiveFolder } = await this.resolveBucketRef( + bucketIdOrRef, + { folderId, folderKey, folderPath }, + 'Buckets.getReadUri', + ); + + if (bucketId <= 0) { + throw new ValidationError({ message: 'Buckets.getReadUri: bucketId must be a positive number.' }); + } + + if (!resolvedPath) { + throw new ValidationError({ message: 'path is required for getReadUri' }); + } + const headers = resolveFolderHeaders({ - folderId, - folderKey, - folderPath, + folderId: effectiveFolder.folderId ?? folderId, + folderKey: effectiveFolder.folderKey ?? folderKey, + folderPath: effectiveFolder.folderPath ?? folderPath, resourceType: 'Buckets.getReadUri', fallbackFolderKey: this.config.folderKey, }); @@ -364,13 +429,6 @@ export class BucketService extends FolderScopedService implements BucketServiceM headers: Record, queryOptions: Record = {} ): Promise { - if (!bucketId) { - throw new ValidationError({ message: 'bucketId is required for getUri' }); - } - - if (!path) { - throw new ValidationError({ message: 'path is required for getUri' }); - } // Filter out undefined values and build query params const queryParams = filterUndefined({ @@ -399,25 +457,47 @@ export class BucketService extends FolderScopedService implements BucketServiceM return transformedData; } + getFiles( + bucketRef: BucketRef, + options?: T + ): Promise< + T extends HasPaginationOptions + ? PaginatedResponse + : NonPaginatedResponse + >; + getFiles( + bucketId: number, + options?: T + ): Promise< + T extends HasPaginationOptions + ? PaginatedResponse + : NonPaginatedResponse + >; @track('Buckets.GetFiles') async getFiles( - bucketId: number, + bucketIdOrRef: number | BucketRef, options?: T ): Promise< T extends HasPaginationOptions ? PaginatedResponse : NonPaginatedResponse > { - if (!bucketId) { - throw new ValidationError({ message: 'bucketId is required for getFiles' }); - } - const { folderId, folderKey, folderPath, ...restOptions } = options ?? {} as BucketGetFilesOptions; + const { id: bucketId, effectiveFolder } = await this.resolveBucketRef( + bucketIdOrRef, + { folderId, folderKey, folderPath }, + 'Buckets.getFiles', + ); + + if (bucketId <= 0) { + throw new ValidationError({ message: 'Buckets.getFiles: bucketId must be a positive number.' }); + } + const headers = resolveFolderHeaders({ - folderId, - folderKey, - folderPath, + folderId: effectiveFolder.folderId ?? folderId, + folderKey: effectiveFolder.folderKey ?? folderKey, + folderPath: effectiveFolder.folderPath ?? folderPath, resourceType: 'Buckets.getFiles', fallbackFolderKey: this.config.folderKey, }); @@ -448,20 +528,28 @@ export class BucketService extends FolderScopedService implements BucketServiceM }, { ...apiRestOptions, directory: '/', recursive: true }) as any; } + deleteFile(bucketRef: BucketRef, path: string, options?: BucketDeleteFileOptions): Promise; + deleteFile(bucketId: number, path: string, options?: BucketDeleteFileOptions): Promise; @track('Buckets.DeleteFile') - async deleteFile(bucketId: number, path: string, options?: BucketDeleteFileOptions): Promise { - if (!bucketId) { - throw new ValidationError({ message: 'bucketId is required for deleteFile' }); - } - + async deleteFile(bucketIdOrRef: number | BucketRef, path: string, options?: BucketDeleteFileOptions): Promise { if (!path) { throw new ValidationError({ message: 'path is required for deleteFile' }); } + const { id: bucketId, effectiveFolder } = await this.resolveBucketRef( + bucketIdOrRef, + { folderId: options?.folderId, folderKey: options?.folderKey, folderPath: options?.folderPath }, + 'Buckets.deleteFile', + ); + + if (bucketId <= 0) { + throw new ValidationError({ message: 'Buckets.deleteFile: bucketId must be a positive number.' }); + } + const headers = resolveFolderHeaders({ - folderId: options?.folderId, - folderKey: options?.folderKey, - folderPath: options?.folderPath, + folderId: effectiveFolder.folderId ?? options?.folderId, + folderKey: effectiveFolder.folderKey ?? options?.folderKey, + folderPath: effectiveFolder.folderPath ?? options?.folderPath, resourceType: 'Buckets.deleteFile', fallbackFolderKey: this.config.folderKey, }); @@ -500,4 +588,41 @@ export class BucketService extends FolderScopedService implements BucketServiceM queryOptions ); } + + /** + * Resolves a `BucketRef | number` first-arg into a numeric bucket id + effective folder. + * Numeric `bucketId` inputs pass through unchanged; `BucketRef` variants delegate to the + * shared {@link resolveRefToId} helper. The `{ name }` branch routes through + * `getByNameLookup` so runtime overrides apply and any override-driven folder redirect is + * propagated back on `effectiveFolder`. + */ + private async resolveBucketRef( + bucketIdOrRef: number | BucketRef | undefined, + folderScope: FolderScopingOnly, + callerLabel: string, + ): Promise> { + // Numeric (including 0 / undefined / null) — pass through so the downstream + // `bucketId <= 0` guard in each file op emits its own tailored message. + if (bucketIdOrRef == null || typeof bucketIdOrRef === 'number') { + return { id: (bucketIdOrRef ?? 0) as number, effectiveFolder: {} }; + } + return resolveRefToId( + bucketIdOrRef, + { + byName: async (name) => { + const { result, effectiveFolder } = await this.getByNameLookup( + 'Bucket', + BUCKET_ENDPOINTS.GET_BY_FOLDER, + name, + folderScope, + (raw) => pascalToCamelCaseKeys(raw) as BucketGetResponse, + undefined, + callerLabel, + ); + return { id: result.id, ...effectiveFolder }; + }, + }, + callerLabel, + ); + } } diff --git a/tests/integration/shared/orchestrator/buckets.integration.test.ts b/tests/integration/shared/orchestrator/buckets.integration.test.ts index b50ba634fe..aeca5ed6a4 100644 --- a/tests/integration/shared/orchestrator/buckets.integration.test.ts +++ b/tests/integration/shared/orchestrator/buckets.integration.test.ts @@ -373,6 +373,68 @@ describe.each(modes)('Orchestrator Buckets - Integration Tests [%s]', (mode) => }); }); + describe('File operations - name-based BucketRef', () => { + let bucketId!: number; + let bucketName!: string; + let folderId!: number; + + beforeAll(async () => { + const { buckets } = getServices(); + const folderIdFromConfig = getFolderId(); + + const allBuckets = await buckets.getAll({ + folderId: folderIdFromConfig, + pageSize: 1, + }); + + if (allBuckets.items.length === 0) { + throw new Error('No buckets available for name-based BucketRef tests'); + } + if (folderIdFromConfig == null) { + throw new Error('INTEGRATION_TEST_FOLDER_ID must be configured for name-based BucketRef tests'); + } + + bucketId = allBuckets.items[0].id; + bucketName = allBuckets.items[0].name; + folderId = folderIdFromConfig; + }); + + it('should get file metadata by { name }', async () => { + const { buckets } = getServices(); + + const result = await buckets.getFileMetaData({ name: bucketName }, { folderId }); + + expect(result).toBeDefined(); + expect(Array.isArray(result.items)).toBe(true); + }); + + it('should list files by { name }', async () => { + const { buckets } = getServices(); + + const result = await buckets.getFiles({ name: bucketName }, { folderId }); + + expect(result).toBeDefined(); + expect(Array.isArray(result.items)).toBe(true); + }); + + it('should upload, get read URI, and delete a file by { name }', async () => { + const { buckets } = getServices(); + const fileName = `/integration-name-ref-${mode}-${Date.now()}.txt`; + const buffer = Buffer.from(createTestFileContent(fileName), 'utf-8'); + + const uploadResult = await buckets.uploadFile({ name: bucketName }, fileName, buffer, { folderId }); + trackUploadedFile(bucketId, fileName, folderId); + + expect(uploadResult.success).toBe(true); + + const readUri = await buckets.getReadUri({ name: bucketName }, fileName, { folderId }); + expect(readUri.uri).toMatch(/^https?:\/\/.+/); + + await buckets.deleteFile({ name: bucketName }, fileName, { folderId }); + untrackUploadedFile(fileName); + }); + }); + describe('Bucket structure validation', () => { it('should have expected fields in bucket objects', async () => { const { buckets } = getServices(); diff --git a/tests/unit/services/orchestrator/buckets.test.ts b/tests/unit/services/orchestrator/buckets.test.ts index 67f52b47f7..6aaa8829e8 100644 --- a/tests/unit/services/orchestrator/buckets.test.ts +++ b/tests/unit/services/orchestrator/buckets.test.ts @@ -14,6 +14,7 @@ import { } from '../../../utils/mocks'; import { createServiceTestDependencies, createMockApiClient } from '../../../utils/setup'; import { TEST_CONSTANTS } from '../../../utils/constants/common'; +import { OVERRIDE_TEST_CONSTANTS } from '../../../utils/constants/overrides'; import type { BucketGetByIdOptions, BucketGetAllOptions, BucketGetFileMetaDataWithPaginationOptions, BucketGetReadUriOptions, BucketGetResponse, BlobItem, BucketGetFilesOptions, BucketFile } from '../../../../src/models/orchestrator/buckets.types'; import { BucketOptions } from '../../../../src/models/orchestrator/buckets.types'; import { BUCKET_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; @@ -594,7 +595,7 @@ describe('BucketService Unit Tests', () => { it('should throw ValidationError when bucketId is missing', async () => { await expect(bucketService.getFileMetaData(null as any, TEST_CONSTANTS.FOLDER_ID)) - .rejects.toThrow('bucketId is required for getFileMetaData'); + .rejects.toThrow('Buckets.getFileMetaData: bucketId must be a positive number.'); }); it('should throw ValidationError when no folder context can be resolved', async () => { @@ -630,6 +631,40 @@ describe('BucketService Unit Tests', () => { }), ); }); + + it('redirects both the internal name-lookup and the meta-data pagination call when a runtime override matches the { name } BucketRef', async () => { + // Sibling to the deleteFile override test — every file op wires resolveBucketRef differently, + // so each needs its own guard per the "sibling methods share a pattern" convention. + const OVERRIDE_KEY = Symbol.for(OVERRIDE_TEST_CONSTANTS.CHANNEL_KEY); + (globalThis as Record)[OVERRIDE_KEY] = () => ({ + [`bucket.${BUCKET_TEST_CONSTANTS.BUCKET_NAME}.Shared/Apps`]: { + name: OVERRIDE_TEST_CONSTANTS.TARGET_NAME, + folderPath: OVERRIDE_TEST_CONSTANTS.TARGET_FOLDER_PATH, + }, + }); + + try { + mockApiClient.get.mockResolvedValue({ + value: [{ Id: BUCKET_TEST_CONSTANTS.BUCKET_ID, Name: OVERRIDE_TEST_CONSTANTS.TARGET_NAME }], + }); + vi.mocked(PaginationHelpers.getAll).mockResolvedValue({ items: [], totalCount: 0 }); + + await bucketService.getFileMetaData( + { name: BUCKET_TEST_CONSTANTS.BUCKET_NAME }, + { folderPath: 'Shared/Apps' }, + ); + + // Lookup GET is scoped to the redirected folder. + const [, getOpts] = mockApiClient.get.mock.calls[0]; + expect(getOpts?.params?.$filter).toBe(`Name eq '${OVERRIDE_TEST_CONSTANTS.TARGET_NAME}'`); + expect(getOpts?.headers?.[FOLDER_PATH_ENCODED]).toBe(OVERRIDE_TEST_CONSTANTS.TARGET_FOLDER_PATH_ENCODED); + // The pagination call's headers carry the same redirected folder. + const [paginationConfig] = vi.mocked(PaginationHelpers.getAll).mock.calls[0]; + expect((paginationConfig as { headers?: Record }).headers?.[FOLDER_PATH_ENCODED]).toBe(OVERRIDE_TEST_CONSTANTS.TARGET_FOLDER_PATH_ENCODED); + } finally { + delete (globalThis as Record)[OVERRIDE_KEY]; + } + }); }); describe('uploadFile', () => { @@ -856,7 +891,7 @@ describe('BucketService Unit Tests', () => { folderId: TEST_CONSTANTS.FOLDER_ID, path: BUCKET_TEST_CONSTANTS.FILE_PATH, content: new Blob([BUCKET_TEST_CONSTANTS.FILE_CONTENT]) - })).rejects.toThrow('bucketId is required for uploadFile'); + })).rejects.toThrow('Buckets.uploadFile: bucketId must be a positive number.'); }); it('should throw ValidationError when no folder context can be resolved', async () => { @@ -979,6 +1014,47 @@ describe('BucketService Unit Tests', () => { content: new Blob([BUCKET_TEST_CONSTANTS.FILE_CONTENT]) })).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); + + it('redirects the internal write-URI GET folder header when a runtime override matches the { name } BucketRef', async () => { + // Sibling to the deleteFile override test — each file op wires resolveBucketRef differently. + const OVERRIDE_KEY = Symbol.for(OVERRIDE_TEST_CONSTANTS.CHANNEL_KEY); + (globalThis as Record)[OVERRIDE_KEY] = () => ({ + [`bucket.${BUCKET_TEST_CONSTANTS.BUCKET_NAME}.Shared/Apps`]: { + name: OVERRIDE_TEST_CONSTANTS.TARGET_NAME, + folderPath: OVERRIDE_TEST_CONSTANTS.TARGET_FOLDER_PATH, + }, + }); + + try { + // First GET = name lookup; second GET = write-URI fetch inside _getWriteUri. + mockApiClient.get + .mockResolvedValueOnce({ + value: [{ Id: BUCKET_TEST_CONSTANTS.BUCKET_ID, Name: OVERRIDE_TEST_CONSTANTS.TARGET_NAME }], + }) + .mockResolvedValueOnce(createMockWriteUriApiResponse({ + RequiresAuth: false, + Headers: { Keys: [], Values: [] }, + })); + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(null, { status: 201 })); + + await bucketService.uploadFile( + { name: BUCKET_TEST_CONSTANTS.BUCKET_NAME }, + BUCKET_TEST_CONSTANTS.FILE_PATH, + new Blob([BUCKET_TEST_CONSTANTS.FILE_CONTENT]), + { folderPath: 'Shared/Apps' }, + ); + + const [, lookupOpts] = mockApiClient.get.mock.calls[0]; + expect(lookupOpts?.params?.$filter).toBe(`Name eq '${OVERRIDE_TEST_CONSTANTS.TARGET_NAME}'`); + expect(lookupOpts?.headers?.[FOLDER_PATH_ENCODED]).toBe(OVERRIDE_TEST_CONSTANTS.TARGET_FOLDER_PATH_ENCODED); + // The write-URI GET scopes to the same redirected folder. + const [writeUriEndpoint, writeUriOpts] = mockApiClient.get.mock.calls[1]; + expect(writeUriEndpoint).toBe(BUCKET_ENDPOINTS.GET_WRITE_URI(BUCKET_TEST_CONSTANTS.BUCKET_ID)); + expect(writeUriOpts?.headers?.[FOLDER_PATH_ENCODED]).toBe(OVERRIDE_TEST_CONSTANTS.TARGET_FOLDER_PATH_ENCODED); + } finally { + delete (globalThis as Record)[OVERRIDE_KEY]; + } + }); }); describe('uploadFile — positional form', () => { @@ -1070,7 +1146,7 @@ describe('BucketService Unit Tests', () => { new Blob([BUCKET_TEST_CONSTANTS.FILE_CONTENT]), { folderId: TEST_CONSTANTS.FOLDER_ID }, ), - ).rejects.toThrow('bucketId is required for uploadFile'); + ).rejects.toThrow('Buckets.uploadFile: bucketId must be a positive number.'); }); it('should throw ValidationError when positional path is missing', async () => { @@ -1134,7 +1210,7 @@ describe('BucketService Unit Tests', () => { bucketId: null as any, folderId: TEST_CONSTANTS.FOLDER_ID, path: BUCKET_TEST_CONSTANTS.FILE_PATH - })).rejects.toThrow('bucketId is required for getUri'); + })).rejects.toThrow('Buckets.getReadUri: bucketId must be a positive number.'); }); it('should throw ValidationError when no folder context can be resolved', async () => { @@ -1210,7 +1286,7 @@ describe('BucketService Unit Tests', () => { bucketId: BUCKET_TEST_CONSTANTS.BUCKET_ID, folderId: TEST_CONSTANTS.FOLDER_ID, path: null as any - })).rejects.toThrow('path is required for getUri'); + })).rejects.toThrow('path is required for getReadUri'); }); it('should handle API errors', async () => { @@ -1243,6 +1319,42 @@ describe('BucketService Unit Tests', () => { }), ); }); + + it('redirects the internal read-URI GET folder header when a runtime override matches the { name } BucketRef', async () => { + // Sibling to the deleteFile override test — each file op wires resolveBucketRef differently. + const OVERRIDE_KEY = Symbol.for(OVERRIDE_TEST_CONSTANTS.CHANNEL_KEY); + (globalThis as Record)[OVERRIDE_KEY] = () => ({ + [`bucket.${BUCKET_TEST_CONSTANTS.BUCKET_NAME}.Shared/Apps`]: { + name: OVERRIDE_TEST_CONSTANTS.TARGET_NAME, + folderPath: OVERRIDE_TEST_CONSTANTS.TARGET_FOLDER_PATH, + }, + }); + + try { + // First GET = name lookup; second GET = read-URI fetch inside _getUri. + mockApiClient.get + .mockResolvedValueOnce({ + value: [{ Id: BUCKET_TEST_CONSTANTS.BUCKET_ID, Name: OVERRIDE_TEST_CONSTANTS.TARGET_NAME }], + }) + .mockResolvedValueOnce(createMockReadUriApiResponse()); + + await bucketService.getReadUri( + { name: BUCKET_TEST_CONSTANTS.BUCKET_NAME }, + BUCKET_TEST_CONSTANTS.FILE_PATH, + { folderPath: 'Shared/Apps' }, + ); + + const [, lookupOpts] = mockApiClient.get.mock.calls[0]; + expect(lookupOpts?.params?.$filter).toBe(`Name eq '${OVERRIDE_TEST_CONSTANTS.TARGET_NAME}'`); + expect(lookupOpts?.headers?.[FOLDER_PATH_ENCODED]).toBe(OVERRIDE_TEST_CONSTANTS.TARGET_FOLDER_PATH_ENCODED); + // The read-URI GET scopes to the same redirected folder. + const [readUriEndpoint, readUriOpts] = mockApiClient.get.mock.calls[1]; + expect(readUriEndpoint).toBe(BUCKET_ENDPOINTS.GET_READ_URI(BUCKET_TEST_CONSTANTS.BUCKET_ID)); + expect(readUriOpts?.headers?.[FOLDER_PATH_ENCODED]).toBe(OVERRIDE_TEST_CONSTANTS.TARGET_FOLDER_PATH_ENCODED); + } finally { + delete (globalThis as Record)[OVERRIDE_KEY]; + } + }); }); describe('getReadUri — positional form', () => { @@ -1317,7 +1429,7 @@ describe('BucketService Unit Tests', () => { BUCKET_TEST_CONSTANTS.FILE_PATH, { folderId: TEST_CONSTANTS.FOLDER_ID }, ), - ).rejects.toThrow('bucketId is required for getUri'); + ).rejects.toThrow('Buckets.getReadUri: bucketId must be a positive number.'); }); it('should throw ValidationError when positional path is missing', async () => { @@ -1327,7 +1439,7 @@ describe('BucketService Unit Tests', () => { null as any, { folderId: TEST_CONSTANTS.FOLDER_ID }, ), - ).rejects.toThrow('path is required for getUri'); + ).rejects.toThrow('path is required for getReadUri'); }); }); @@ -1376,7 +1488,7 @@ describe('BucketService Unit Tests', () => { null as any, BUCKET_TEST_CONSTANTS.FILE_PATH, { folderId: TEST_CONSTANTS.FOLDER_ID }, - )).rejects.toThrow('bucketId is required for deleteFile'); + )).rejects.toThrow('Buckets.deleteFile: bucketId must be a positive number.'); expect(mockApiClient.delete).not.toHaveBeenCalled(); }); @@ -1407,6 +1519,71 @@ describe('BucketService Unit Tests', () => { { folderId: TEST_CONSTANTS.FOLDER_ID }, )).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); + + it('accepts a { name } BucketRef, resolves the bucket id via getByName, then deletes', async () => { + // First call: the getByNameLookup GET (OData $filter=Name eq ...) → returns the bucket row. + mockApiClient.get.mockResolvedValue({ + value: [{ Id: BUCKET_TEST_CONSTANTS.BUCKET_ID, Name: BUCKET_TEST_CONSTANTS.BUCKET_NAME }], + }); + mockApiClient.delete.mockResolvedValue(undefined); + + await bucketService.deleteFile( + { name: BUCKET_TEST_CONSTANTS.BUCKET_NAME }, + BUCKET_TEST_CONSTANTS.FILE_PATH, + { folderId: TEST_CONSTANTS.FOLDER_ID }, + ); + + // getByNameLookup fires the OData Name eq filter. + expect(mockApiClient.get).toHaveBeenCalledWith( + BUCKET_ENDPOINTS.GET_BY_FOLDER, + expect.objectContaining({ + params: expect.objectContaining({ + '$filter': `Name eq '${BUCKET_TEST_CONSTANTS.BUCKET_NAME}'`, + }), + }), + ); + // Then delete targets the resolved bucket id. + expect(mockApiClient.delete).toHaveBeenCalledWith( + BUCKET_ENDPOINTS.DELETE_FILE(BUCKET_TEST_CONSTANTS.BUCKET_ID), + expect.objectContaining({ + params: { path: BUCKET_TEST_CONSTANTS.FILE_PATH }, + }), + ); + }); + + it('redirects the follow-up DELETE folder header when a runtime override matches the { name } BucketRef', async () => { + // Cross-folder override representative for all 5 file ops — they all share the same + // `_resolveBucketRef` helper. One test guards the shared code path. + const OVERRIDE_KEY = Symbol.for(OVERRIDE_TEST_CONSTANTS.CHANNEL_KEY); + (globalThis as Record)[OVERRIDE_KEY] = () => ({ + [`bucket.${BUCKET_TEST_CONSTANTS.BUCKET_NAME}.Shared/Apps`]: { + name: OVERRIDE_TEST_CONSTANTS.TARGET_NAME, + folderPath: OVERRIDE_TEST_CONSTANTS.TARGET_FOLDER_PATH, + }, + }); + + try { + mockApiClient.get.mockResolvedValue({ + value: [{ Id: BUCKET_TEST_CONSTANTS.BUCKET_ID, Name: OVERRIDE_TEST_CONSTANTS.TARGET_NAME }], + }); + mockApiClient.delete.mockResolvedValue(undefined); + + await bucketService.deleteFile( + { name: BUCKET_TEST_CONSTANTS.BUCKET_NAME }, + BUCKET_TEST_CONSTANTS.FILE_PATH, + { folderPath: 'Shared/Apps' }, + ); + + // Both the lookup GET and the follow-up DELETE header scope to the redirected folder. + const [, getOpts] = mockApiClient.get.mock.calls[0]; + expect(getOpts?.params?.$filter).toBe(`Name eq '${OVERRIDE_TEST_CONSTANTS.TARGET_NAME}'`); + expect(getOpts?.headers?.[FOLDER_PATH_ENCODED]).toBe(OVERRIDE_TEST_CONSTANTS.TARGET_FOLDER_PATH_ENCODED); + const [, deleteOpts] = mockApiClient.delete.mock.calls[0]; + expect(deleteOpts?.headers?.[FOLDER_PATH_ENCODED]).toBe(OVERRIDE_TEST_CONSTANTS.TARGET_FOLDER_PATH_ENCODED); + } finally { + delete (globalThis as Record)[OVERRIDE_KEY]; + } + }); }); describe('getFiles', () => { @@ -1555,7 +1732,13 @@ describe('BucketService Unit Tests', () => { it('should throw ValidationError when bucketId is missing', async () => { await expect(bucketService.getFiles(null as any, { folderId: TEST_CONSTANTS.FOLDER_ID })) - .rejects.toThrow('bucketId is required for getFiles'); + .rejects.toThrow('Buckets.getFiles: bucketId must be a positive number.'); + expect(PaginationHelpers.getAll).not.toHaveBeenCalled(); + }); + + it('should throw ValidationError when bucketId is negative', async () => { + await expect(bucketService.getFiles(-1, { folderId: TEST_CONSTANTS.FOLDER_ID })) + .rejects.toThrow('Buckets.getFiles: bucketId must be a positive number.'); expect(PaginationHelpers.getAll).not.toHaveBeenCalled(); }); @@ -1591,6 +1774,38 @@ describe('BucketService Unit Tests', () => { }), ); }); + + it('redirects both the internal name-lookup and the getFiles pagination call when a runtime override matches the { name } BucketRef', async () => { + // Sibling to the deleteFile override test — each file op wires resolveBucketRef differently. + const OVERRIDE_KEY = Symbol.for(OVERRIDE_TEST_CONSTANTS.CHANNEL_KEY); + (globalThis as Record)[OVERRIDE_KEY] = () => ({ + [`bucket.${BUCKET_TEST_CONSTANTS.BUCKET_NAME}.Shared/Apps`]: { + name: OVERRIDE_TEST_CONSTANTS.TARGET_NAME, + folderPath: OVERRIDE_TEST_CONSTANTS.TARGET_FOLDER_PATH, + }, + }); + + try { + mockApiClient.get.mockResolvedValue({ + value: [{ Id: BUCKET_TEST_CONSTANTS.BUCKET_ID, Name: OVERRIDE_TEST_CONSTANTS.TARGET_NAME }], + }); + vi.mocked(PaginationHelpers.getAll).mockResolvedValue({ items: [], totalCount: 0 }); + + await bucketService.getFiles( + { name: BUCKET_TEST_CONSTANTS.BUCKET_NAME }, + { folderPath: 'Shared/Apps' }, + ); + + const [, lookupOpts] = mockApiClient.get.mock.calls[0]; + expect(lookupOpts?.params?.$filter).toBe(`Name eq '${OVERRIDE_TEST_CONSTANTS.TARGET_NAME}'`); + expect(lookupOpts?.headers?.[FOLDER_PATH_ENCODED]).toBe(OVERRIDE_TEST_CONSTANTS.TARGET_FOLDER_PATH_ENCODED); + // Pagination config's headers scope to the same redirected folder. + const [paginationConfig] = vi.mocked(PaginationHelpers.getAll).mock.calls[0]; + expect((paginationConfig as { headers?: Record }).headers?.[FOLDER_PATH_ENCODED]).toBe(OVERRIDE_TEST_CONSTANTS.TARGET_FOLDER_PATH_ENCODED); + } finally { + delete (globalThis as Record)[OVERRIDE_KEY]; + } + }); }); });