diff --git a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs index f6704ea0bd..a7c63f8270 100644 --- a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs +++ b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs @@ -167,6 +167,21 @@ public class JQueryProxyScriptGenerator_ContentTypes_Tests script.ShouldNotContain("JSON.stringify"); } + [Fact] + public void Multiple_Direct_FormFile_Params_Should_Forward_Only_First_Var_Known_Limitation() + { + var script = _generator.CreateScript(BuildUploadModel( + uploadParameters: new[] + { + ("file1", "file1", ParameterBindingSources.FormFile), + ("file2", "file2", ParameterBindingSources.FormFile), + })); + + script.ShouldContain("data: file1"); + script.ShouldNotContain("data: file2"); + script.ShouldNotContain("$.merge(file1, file2)"); + } + private static ApplicationApiDescriptionModel BuildAppModel(string returnType, IList? contentTypes, bool isRemoteStream = false) { var model = ApplicationApiDescriptionModel.Create(); diff --git a/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts b/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts index 7156759689..b080144e8b 100644 --- a/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts +++ b/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts @@ -3,7 +3,11 @@ import { join } from 'path'; import { describe, expect, test } from 'vitest'; import { eBindingSourceId } from '../enums'; import { Action } from '../models'; -import { createActionToBodyMapper } from '../utils/service'; +import { + createActionToBodyMapper, + createActionToMethodMapper, + createActionToSignatureMapper, +} from '../utils/service'; const TEMPLATE_PATH = join( __dirname, @@ -704,3 +708,125 @@ describe('createActionToBodyMapper — multipart upload params regression', () = expect(body.params.join(',')).not.toContain('name'); }); }); + +describe('createActionToSignatureMapper — multipart upload signature collapse', () => { + const mapSignature = createActionToSignatureMapper(); + + test('FormFile DTO method arg collapses to FormData type', () => { + const sig = mapSignature(buildAction({ + httpMethod: 'POST', + url: 'api/app/proxy-demo-test/upload-single', + parametersOnMethod: [ + { name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any, + ], + parameters: [ + { nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, + { nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial)); + const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`); + expect(types).toContain('input:FormData'); + expect(types[types.length - 1]).toBe('config:Partial'); + }); + + test('Path arg keeps its primitive type while upload arg becomes FormData', () => { + const sig = mapSignature(buildAction({ + httpMethod: 'POST', + url: 'api/proxy-demo/media/upload-with-path/{id}', + parametersOnMethod: [ + { name: 'id', type: 'System.Int32', typeAsString: 'System.Int32', typeSimple: 'number', isOptional: false, defaultValue: null } as any, + { name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any, + ], + parameters: [ + { nameOnMethod: 'id', name: 'id', type: 'System.Int32', typeSimple: 'int', bindingSourceId: eBindingSourceId.Path } as any, + { nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, + { nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial)); + const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`); + expect(types).toEqual([ + 'id:number', + 'input:FormData', + 'config:Partial', + ]); + }); + + test('Multiple direct IRemoteStreamContent method args each become FormData independently', () => { + const sig = mapSignature(buildAction({ + httpMethod: 'POST', + url: 'api/proxy-demo/media/upload-two-direct', + parametersOnMethod: [ + { name: 'file1', type: 'Volo.Abp.Content.IRemoteStreamContent', typeAsString: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', isOptional: false, defaultValue: null } as any, + { name: 'file2', type: 'Volo.Abp.Content.IRemoteStreamContent', typeAsString: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', isOptional: false, defaultValue: null } as any, + ], + parameters: [ + { nameOnMethod: 'file1', name: 'file1', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + { nameOnMethod: 'file2', name: 'file2', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial)); + const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`); + expect(types).toEqual([ + 'file1:FormData', + 'file2:FormData', + 'config:Partial', + ]); + }); + + test('Non-upload action signature stays untouched (regression guard)', () => { + const sig = mapSignature(buildAction({ + httpMethod: 'GET', + url: 'api/app/proxy-demo-test/get-item-by-id', + parametersOnMethod: [ + { name: 'id', type: 'System.Int32', typeAsString: 'System.Int32', typeSimple: 'number', isOptional: false, defaultValue: null } as any, + ], + parameters: [ + { nameOnMethod: 'id', name: 'id', type: 'System.Int32', typeSimple: 'int', bindingSourceId: eBindingSourceId.Path } as any, + ], + } as Partial)); + const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`); + expect(types).toEqual(['id:number', 'config:Partial']); + }); +}); + +describe('createActionToMethodMapper — signature + body wired together for upload actions', () => { + const mapMethod = createActionToMethodMapper(); + + test('Upload action produces both FormData signature and FormData body in one pass', () => { + const method = mapMethod(buildAction({ + httpMethod: 'POST', + url: 'api/app/proxy-demo-test/upload-single', + parametersOnMethod: [ + { name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any, + ], + parameters: [ + { nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, + { nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial)); + const sigTypes = method.signature.parameters.map((p: any) => `${p.name}:${p.type}`); + expect(sigTypes).toContain('input:FormData'); + expect(method.body.body).toBe('input'); + expect(method.body.params.join(',')).not.toContain('name'); + }); + + test('Query + ModelBinding + FormFile mix preserves Query as URL param and drops upload-arg fields', () => { + const method = mapMethod(buildAction({ + httpMethod: 'POST', + url: 'api/proxy-demo/media/upload-with-query', + parametersOnMethod: [ + { name: 'tag', type: 'System.String', typeAsString: 'System.String', typeSimple: 'string', isOptional: false, defaultValue: null } as any, + { name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any, + ], + parameters: [ + { nameOnMethod: 'tag', name: 'tag', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Query } as any, + { nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, + { nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial)); + const sigTypes = method.signature.parameters.map((p: any) => `${p.name}:${p.type}`); + expect(sigTypes).toEqual(['tag:string', 'input:FormData', 'config:Partial']); + expect(method.body.body).toBe('input'); + expect(method.body.params).toContain('tag'); + expect(method.body.params.join(',')).not.toContain('name'); + }); +});