@@ -773,6 +773,197 @@ describe('actions/Policy', () => {
773773 expect ( messages ?. at ( 0 ) ?. text ) . toBe ( callerEmail ) ;
774774 } ) ;
775775
776+ it ( 'duplicate workspace with distance rates clones all source rates in optimistic data with the default rebound to the API-known rate ID' , async ( ) => {
777+ await Onyx . set ( ONYXKEYS . SESSION , { email : ESH_EMAIL , accountID : ESH_ACCOUNT_ID } ) ;
778+
779+ const sourceDistanceUnitID = 'srcDistUnit' ;
780+ const sourceDefaultRateID = 'srcDefaultRate' ;
781+ const sourceExtraRateID = 'srcExtraRate' ;
782+ const fakePolicy : PolicyType = {
783+ ...createRandomPolicy ( 19 , CONST . POLICY . TYPE . TEAM ) ,
784+ customUnits : {
785+ [ sourceDistanceUnitID ] : {
786+ customUnitID : sourceDistanceUnitID ,
787+ name : CONST . CUSTOM_UNITS . NAME_DISTANCE ,
788+ enabled : true ,
789+ attributes : { unit : CONST . CUSTOM_UNITS . DISTANCE_UNIT_MILES } ,
790+ rates : {
791+ [ sourceDefaultRateID ] : { customUnitRateID : sourceDefaultRateID , name : 'Default Rate' , rate : 72.5 , currency : 'USD' , enabled : true , index : 0 } ,
792+ [ sourceExtraRateID ] : { customUnitRateID : sourceExtraRateID , name : 'Extra Rate' , rate : 100 , currency : 'USD' , enabled : true , index : 1 } ,
793+ } ,
794+ } ,
795+ } ,
796+ } ;
797+ await Onyx . set ( `${ ONYXKEYS . COLLECTION . POLICY } ${ fakePolicy . id } ` , fakePolicy ) ;
798+ await waitForBatchedUpdates ( ) ;
799+
800+ const policyID = Policy . generatePolicyID ( ) ;
801+ const options = {
802+ currentUserAccountID : ESH_ACCOUNT_ID ,
803+ currentUserEmail : ESH_EMAIL ,
804+ policyName : 'Distance Rates Duplicate' ,
805+ policyID : fakePolicy . id ,
806+ targetPolicyID : policyID ,
807+ welcomeNote : 'Join my policy' ,
808+ parts : {
809+ people : false ,
810+ reports : false ,
811+ connections : false ,
812+ categories : false ,
813+ tags : false ,
814+ taxes : false ,
815+ perDiem : false ,
816+ reimbursements : false ,
817+ expenses : false ,
818+ distance : true ,
819+ invoices : false ,
820+ exportLayouts : false ,
821+ } ,
822+ localCurrency : 'USD' ,
823+ } ;
824+
825+ // Pause the API call so successData (which clears the optimistic source rate IDs) does not
826+ // run yet — this lets us inspect the optimistic state the user sees offline.
827+ mockFetch . pause ( ) ;
828+ Policy . duplicateWorkspace ( fakePolicy , options ) ;
829+ await waitForBatchedUpdates ( ) ;
830+
831+ const duplicatePolicy : OnyxEntry < PolicyType > = await new Promise ( ( resolve ) => {
832+ const connection = Onyx . connect ( {
833+ key : `${ ONYXKEYS . COLLECTION . POLICY } ${ policyID } ` ,
834+ callback : ( workspace ) => {
835+ Onyx . disconnect ( connection ) ;
836+ resolve ( workspace ) ;
837+ } ,
838+ } ) ;
839+ } ) ;
840+
841+ expect ( duplicatePolicy ?. areDistanceRatesEnabled ) . toBe ( true ) ;
842+ const distanceUnit = Object . values ( duplicatePolicy ?. customUnits ?? { } ) . find ( ( unit ) => unit . name === CONST . CUSTOM_UNITS . NAME_DISTANCE ) ;
843+ if ( ! distanceUnit ) {
844+ throw new Error ( 'Expected duplicated distance unit' ) ;
845+ }
846+ // The duplicated unit's customUnitID should differ from the source's (fresh ID).
847+ expect ( distanceUnit . customUnitID ) . not . toBe ( sourceDistanceUnitID ) ;
848+ // Both rates should be present in optimistic data: the extra rate keeps its source ID,
849+ // and the default is rebound to a fresh API-known customUnitRateID.
850+ const rateKeys = Object . keys ( distanceUnit . rates ) ;
851+ expect ( rateKeys ) . toContain ( sourceExtraRateID ) ;
852+ expect ( rateKeys ) . not . toContain ( sourceDefaultRateID ) ;
853+ expect ( rateKeys ) . toHaveLength ( 2 ) ;
854+ // The Default Rate should be the one picked by getDefaultDistanceRate (lowest enabled index).
855+ const defaultRate = Object . values ( distanceUnit . rates )
856+ . filter ( ( rate ) => rate . enabled !== false )
857+ . sort ( ( a , b ) => ( a . index ?? CONST . DEFAULT_NUMBER_ID ) - ( b . index ?? CONST . DEFAULT_NUMBER_ID ) )
858+ . at ( 0 ) ;
859+ expect ( defaultRate ?. name ) . toBe ( 'Default Rate' ) ;
860+ expect ( defaultRate ?. rate ) . toBe ( 72.5 ) ;
861+ // The default rate's customUnitRateID matches its dictionary key (rebound).
862+ expect ( defaultRate ?. customUnitRateID ) . not . toBe ( sourceDefaultRateID ) ;
863+ const defaultRateID = defaultRate ?. customUnitRateID ;
864+ if ( ! defaultRateID ) {
865+ throw new Error ( 'Expected default rate to have a customUnitRateID' ) ;
866+ }
867+ expect ( distanceUnit . rates [ defaultRateID ] ) . toBe ( defaultRate ) ;
868+
869+ // Resume the mocked fetch so successData fires and clears the optimistic source rate IDs.
870+ // After success, only the rebound default remains until a Pusher response (not mocked here)
871+ // would repopulate the rates with fresh server IDs.
872+ await mockFetch . resume ?.( ) ;
873+ await waitForBatchedUpdates ( ) ;
874+
875+ const duplicateAfterSuccess : OnyxEntry < PolicyType > = await new Promise ( ( resolve ) => {
876+ const connection = Onyx . connect ( {
877+ key : `${ ONYXKEYS . COLLECTION . POLICY } ${ policyID } ` ,
878+ callback : ( workspace ) => {
879+ Onyx . disconnect ( connection ) ;
880+ resolve ( workspace ) ;
881+ } ,
882+ } ) ;
883+ } ) ;
884+
885+ const distanceUnitAfter = Object . values ( duplicateAfterSuccess ?. customUnits ?? { } ) . find ( ( unit ) => unit . name === CONST . CUSTOM_UNITS . NAME_DISTANCE ) ;
886+ if ( ! distanceUnitAfter ) {
887+ throw new Error ( 'Expected duplicated distance unit to still exist after success' ) ;
888+ }
889+ const ratesAfter = Object . entries ( distanceUnitAfter . rates ) . filter ( ( [ , rate ] ) => rate !== null ) ;
890+ expect ( ratesAfter ) . toHaveLength ( 1 ) ;
891+ expect ( ratesAfter . at ( 0 ) ?. [ 1 ] ?. name ) . toBe ( 'Default Rate' ) ;
892+ } ) ;
893+
894+ it ( 'duplicate workspace without distance rates does not write any customUnits cleanup for the source distance rates' , async ( ) => {
895+ await Onyx . set ( ONYXKEYS . SESSION , { email : ESH_EMAIL , accountID : ESH_ACCOUNT_ID } ) ;
896+
897+ const sourceDistanceUnitID = 'srcDistUnitB' ;
898+ const sourceExtraRateID = 'srcExtraRateB' ;
899+ const fakePolicy : PolicyType = {
900+ ...createRandomPolicy ( 20 , CONST . POLICY . TYPE . TEAM ) ,
901+ customUnits : {
902+ [ sourceDistanceUnitID ] : {
903+ customUnitID : sourceDistanceUnitID ,
904+ name : CONST . CUSTOM_UNITS . NAME_DISTANCE ,
905+ enabled : true ,
906+ attributes : { unit : CONST . CUSTOM_UNITS . DISTANCE_UNIT_MILES } ,
907+ rates : {
908+ srcDefaultRateB : { customUnitRateID : 'srcDefaultRateB' , name : 'Default Rate' , rate : 72.5 , currency : 'USD' , enabled : true , index : 0 } ,
909+ [ sourceExtraRateID ] : { customUnitRateID : sourceExtraRateID , name : 'Extra Rate' , rate : 100 , currency : 'USD' , enabled : true , index : 1 } ,
910+ } ,
911+ } ,
912+ } ,
913+ } ;
914+ await Onyx . set ( `${ ONYXKEYS . COLLECTION . POLICY } ${ fakePolicy . id } ` , fakePolicy ) ;
915+ await waitForBatchedUpdates ( ) ;
916+
917+ const policyID = Policy . generatePolicyID ( ) ;
918+ const options = {
919+ currentUserAccountID : ESH_ACCOUNT_ID ,
920+ currentUserEmail : ESH_EMAIL ,
921+ policyName : 'No Distance Duplicate' ,
922+ policyID : fakePolicy . id ,
923+ targetPolicyID : policyID ,
924+ welcomeNote : 'Join my policy' ,
925+ parts : {
926+ people : false ,
927+ reports : false ,
928+ connections : false ,
929+ categories : false ,
930+ tags : false ,
931+ taxes : false ,
932+ perDiem : false ,
933+ reimbursements : false ,
934+ expenses : false ,
935+ distance : false ,
936+ invoices : false ,
937+ exportLayouts : false ,
938+ } ,
939+ localCurrency : 'USD' ,
940+ } ;
941+
942+ Policy . duplicateWorkspace ( fakePolicy , options ) ;
943+ await waitForBatchedUpdates ( ) ;
944+
945+ const duplicatePolicy : OnyxEntry < PolicyType > = await new Promise ( ( resolve ) => {
946+ const connection = Onyx . connect ( {
947+ key : `${ ONYXKEYS . COLLECTION . POLICY } ${ policyID } ` ,
948+ callback : ( workspace ) => {
949+ Onyx . disconnect ( connection ) ;
950+ resolve ( workspace ) ;
951+ } ,
952+ } ) ;
953+ } ) ;
954+
955+ // The duplicate should not have any distance custom unit because parts.distance is false.
956+ // In particular, it should not contain a partial/orphan distance unit consisting only of
957+ // nulled-out source rate IDs (which would be the regression the gating prevents).
958+ const distanceUnits = Object . values ( duplicatePolicy ?. customUnits ?? { } ) . filter ( ( unit ) => unit . name === CONST . CUSTOM_UNITS . NAME_DISTANCE ) ;
959+ expect ( distanceUnits ) . toHaveLength ( 0 ) ;
960+ // The source's extra rate ID must not appear anywhere in the duplicate's customUnits as
961+ // a stale "null" entry from successData cleanup.
962+ for ( const unit of Object . values ( duplicatePolicy ?. customUnits ?? { } ) ) {
963+ expect ( Object . keys ( unit . rates ?? { } ) ) . not . toContain ( sourceExtraRateID ) ;
964+ }
965+ } ) ;
966+
776967 it ( 'creates a new workspace with BASIC approval mode if the introSelected is MANAGE_TEAM' , async ( ) => {
777968 const policyID = Policy . generatePolicyID ( ) ;
778969 // When a new workspace is created with introSelected set to MANAGE_TEAM
0 commit comments