@@ -6,7 +6,7 @@ import { bucketDao } from './dao/bucket.dao.js'
66import { experimentDao } from './dao/experiment.dao.js'
77import { userAssignmentDao } from './dao/userAssignment.dao.js'
88import { mockBucket , mockExperiment , mockUserAssignment , mockUserId1 } from './test/mocks.js'
9- import { AssignmentStatus } from './types.js'
9+ import { AssignmentStatus , SegmentationRuleOperator } from './types.js'
1010import type { DecoratedUserAssignment } from './types.js'
1111
1212const db = new InMemoryDB ( { logger : undefined } )
@@ -530,3 +530,120 @@ describe('softDeleteExperiment', () => {
530530 expect ( updatedExperiment2 . exclusions ) . toEqual ( [ ] )
531531 } )
532532} )
533+
534+ describe ( 'saveManualUserAssignments' , ( ) => {
535+ test ( 'should create new assignments and overwrite existing ones in a single batch' , async ( ) => {
536+ const experiment1 = await experimentsDAO . save ( mockExperiment ( { key : 'EXP_1' } ) )
537+ const control1 = await bucketsDAO . save ( mockBucket ( experiment1 . id , 'control' , 50 ) )
538+ const test1 = await bucketsDAO . save ( mockBucket ( experiment1 . id , 'test' , 50 ) )
539+ const experiment2 = await experimentsDAO . save ( mockExperiment ( { key : 'EXP_2' } ) )
540+ const test2 = await bucketsDAO . save ( mockBucket ( experiment2 . id , 'test' , 100 ) )
541+ const existing = await userAssignmentsDAO . save ( {
542+ userId : 'userA' ,
543+ experimentId : experiment1 . id ,
544+ bucketId : control1 . id ,
545+ } )
546+
547+ const results = await abba . saveManualUserAssignments ( [
548+ { userId : 'userA' , experimentKey : 'EXP_1' , bucketKey : 'test' } ,
549+ { userId : 'userB' , experimentKey : 'EXP_1' , bucketKey : 'control' } ,
550+ { userId : 'userA' , experimentKey : 'EXP_2' , bucketKey : 'test' } ,
551+ ] )
552+
553+ expect ( results ) . toHaveLength ( 3 )
554+ expect ( results [ 0 ] ) . toMatchObject ( { id : existing . id , userId : 'userA' , bucketId : test1 . id } )
555+ expect ( results [ 1 ] ) . toMatchObject ( { userId : 'userB' , bucketId : control1 . id } )
556+ expect ( results [ 2 ] ) . toMatchObject ( { userId : 'userA' , bucketId : test2 . id } )
557+ const all = await userAssignmentsDAO . getBy ( 'userId' , 'userA' )
558+ expect ( all ) . toHaveLength ( 2 )
559+ } )
560+
561+ test ( 'should return an empty array for an empty input' , async ( ) => {
562+ const result = await abba . saveManualUserAssignments ( [ ] )
563+ expect ( result ) . toEqual ( [ ] )
564+ } )
565+
566+ test ( 'should apply last-wins dedupe for duplicate (userId, experimentKey) pairs' , async ( ) => {
567+ const experiment = await experimentsDAO . save ( mockExperiment ( ) )
568+ await bucketsDAO . save ( mockBucket ( experiment . id , 'control' , 50 ) )
569+ const testBucket = await bucketsDAO . save ( mockBucket ( experiment . id , 'test' , 50 ) )
570+
571+ const result = await abba . saveManualUserAssignments ( [
572+ { userId : mockUserId1 , experimentKey : experiment . key , bucketKey : 'control' } ,
573+ { userId : mockUserId1 , experimentKey : experiment . key , bucketKey : 'test' } ,
574+ ] )
575+
576+ expect ( result ) . toHaveLength ( 1 )
577+ expect ( result [ 0 ] ! . bucketId ) . toBe ( testBucket . id )
578+ const persisted = await userAssignmentsDAO . getBy ( 'userId' , mockUserId1 )
579+ expect ( persisted ) . toHaveLength ( 1 )
580+ expect ( persisted [ 0 ] ! . bucketId ) . toBe ( testBucket . id )
581+ } )
582+
583+ test ( 'should throw when any row references an unknown experiment' , async ( ) => {
584+ const experiment = await experimentsDAO . save ( mockExperiment ( ) )
585+ await bucketsDAO . save ( mockBucket ( experiment . id , 'test' , 100 ) )
586+
587+ await expect (
588+ abba . saveManualUserAssignments ( [
589+ { userId : 'userA' , experimentKey : experiment . key , bucketKey : 'test' } ,
590+ { userId : 'userB' , experimentKey : 'NOPE' , bucketKey : 'test' } ,
591+ ] ) ,
592+ ) . rejects . toThrow ( 'Experiment does not exist: NOPE' )
593+ const written = await userAssignmentsDAO . getBy ( 'userId' , 'userA' )
594+ expect ( written ) . toEqual ( [ ] )
595+ } )
596+
597+ test ( 'should throw when the experiment is soft-deleted' , async ( ) => {
598+ const experiment = await experimentsDAO . save ( mockExperiment ( { deleted : true } ) )
599+ await bucketsDAO . save ( mockBucket ( experiment . id , 'test' , 100 ) )
600+
601+ await expect (
602+ abba . saveManualUserAssignments ( [
603+ { userId : mockUserId1 , experimentKey : experiment . key , bucketKey : 'test' } ,
604+ ] ) ,
605+ ) . rejects . toThrow ( `Experiment does not exist: ${ experiment . key } ` )
606+ } )
607+
608+ test ( 'should throw when a bucket key does not belong to its experiment' , async ( ) => {
609+ const experiment = await experimentsDAO . save ( mockExperiment ( ) )
610+ await bucketsDAO . save ( mockBucket ( experiment . id , 'test' , 100 ) )
611+
612+ await expect (
613+ abba . saveManualUserAssignments ( [
614+ { userId : mockUserId1 , experimentKey : experiment . key , bucketKey : 'control' } ,
615+ ] ) ,
616+ ) . rejects . toThrow ( `Bucket does not exist on experiment ${ experiment . key } : control` )
617+ } )
618+
619+ test . each ( [
620+ [ 'Active' , AssignmentStatus . Active ] ,
621+ [ 'Paused' , AssignmentStatus . Paused ] ,
622+ [ 'Inactive' , AssignmentStatus . Inactive ] ,
623+ ] ) ( 'should succeed when the experiment status is %s' , async ( _name , status ) => {
624+ const experiment = await experimentsDAO . save ( mockExperiment ( { status } ) )
625+ const bucket = await bucketsDAO . save ( mockBucket ( experiment . id , 'test' , 100 ) )
626+
627+ const [ result ] = await abba . saveManualUserAssignments ( [
628+ { userId : mockUserId1 , experimentKey : experiment . key , bucketKey : bucket . key } ,
629+ ] )
630+
631+ expect ( result ! . bucketId ) . toBe ( bucket . id )
632+ } )
633+
634+ test ( 'should ignore segmentation rules and sampling' , async ( ) => {
635+ const experiment = await experimentsDAO . save (
636+ mockExperiment ( {
637+ sampling : 0 ,
638+ rules : [ { key : 'country' , operator : SegmentationRuleOperator . EqualsText , value : 'SE' } ] ,
639+ } ) ,
640+ )
641+ const bucket = await bucketsDAO . save ( mockBucket ( experiment . id , 'test' , 100 ) )
642+
643+ const [ result ] = await abba . saveManualUserAssignments ( [
644+ { userId : mockUserId1 , experimentKey : experiment . key , bucketKey : bucket . key } ,
645+ ] )
646+
647+ expect ( result ! . bucketId ) . toBe ( bucket . id )
648+ } )
649+ } )
0 commit comments