1

I am making a site to maintain some devices. There is a reactive form to create new devices, and because i will need it on multiple pages, i made a dedicated component with the device FormGroup. This way i can reuse it in other forms. The device also has an access-control-list (acl) with rules that determine who can access the device and who not. This acl is also used for locations, and users. So i made an acl-form component, to be able to reuse this form. This acl-form populates a formArray.

The acl-form has 2 extra functions, the prepareACL and the sync. The first function is used to create the FormControls for the access control entries, and to populate them with data. the second function is used to do some data manipulation because the data in the form can't be send to the server as-is.

This all works fine, but now i wanted to write some tests for the forms. Testing the acl-form itself was no problem, because all the dependencies could be easily mocked. The device-form on the other hand is a lot harder. When i was writing the tests, i could only get it to work if i mocked all the dependencies of the device-form (what is normal) and the dependencies of the acl-form. But as the acl-form is already tested by itself, i don't want to bother about it.

My questions are, how can i mock the acl-form so i don't need to provide all its dependencies, and how can i spy on the prepareACL-function and sync-function, to make sure they are called?

here is my code:

acl-form.component.ts:

import { Component, Input, OnInit } from '@angular/core';
import { FormBuilder, FormArray, Validators } from '@angular/forms';

import { UserService } from '../../../services/user.service';

@Component({
    selector: 'acl-form',
    templateUrl: './acl-form.component.html'
})
export class ACLFormComponent implements OnInit {
    @Input()
    public group: FormArray;
    public users: Object[];

    public formErrors = {};

    build() {
        let group = this.fb.array([]);
        return group;
    }

    constructor(private fb: FormBuilder, private userService: UserService) { }

    ngOnInit() {
        this.userService.getUsers().subscribe(users => {
            this.users = users;
        });
    }

    buildACE() {
        let group = this.fb.group({
            guid: ['', []],
            user: ['', [Validators.required]],
            permission: ['', []],
            read: [{ value: '', disabled: true }, []],
            write: [{ value: '', disabled: true }, []],
            execute: [{ value: '', disabled: true }, []]
        });

        group.controls['user'].valueChanges.subscribe(guid => {
            if (guid && guid !== '') {
                group.controls['read'].enable();
                group.controls['write'].enable();
                group.controls['execute'].enable();
            } else {
                group.controls['read'].disable();
                group.controls['write'].disable();
                group.controls['execute'].disable();
            }
        });
        return group;
    }

    createACEs(count: Number) {
        const control = this.group;
        while (control.length) {
            control.removeAt(0);
        }
        for (let i = 0; i < count; i++) {
            control.push(this.buildACE());
        }
    }

    addACE() {
        this.group.push(this.buildACE());
    }

    removeACE(i: number) {
        this.group.removeAt(i);
    }

    encodePermission(ace) {
        let read = ace.read;
        let write = ace.write;
        let execute = ace.execute;

        let permission = 0;
        permission += read ? 4 : 0;
        permission += write ? 2 : 0;
        permission += execute ? 1 : 0;
        ace.permission = permission;
    }

    decodePermission(ace) {
        let permission = ace.permission;
        ace.read = (permission & 4) > 0;
        ace.write = (permission & 2) > 0;
        ace.execute = (permission & 1) > 0;
    }

    encodePermissions() {
        let acl = this.group.value;
        for (let i = 0; i < acl.length; i++) {
            this.encodePermission(acl[i]);
            // remove secondary fields to`enter code here` prevent api necking about it
            delete acl[i].read;
            delete acl[i].write;
            delete acl[i].execute;
            // remove guid to prevent api necking about it
            if (acl[i].guid === '') {
                delete acl[i].guid;
            }
        }
    }

    decodePermissions(acl) {
        for (let i = 0; i < acl.length; i++) {
            this.decodePermission(acl[i]);
        }
    }

    prepareACL(acl) {
        this.createACEs(acl.length);
        this.decodePermissions(acl);
    }

    // assign removedACE to the entity
    handleRemovedACE(entity) {
        let acl = this.group.value;
        if (entity.acl) {
            // remove acl
            acl = acl.filter(x => x.permission > 0);
            entity.removedACE = [...entity.acl].filter(x => acl.find(y => y.guid === x['guid']) === undefined)
                .map(x => x['guid']);

        } else {
            console.error('no acl entry found');
        }
        entity.acl = acl;
    }

    sync(entity) {
        this.encodePermissions();
        this.handleRemovedACE(entity);
    }
}

device.component.ts:

import { Component, Input, OnInit, ViewChild } from '@angular/core';
import { FormGroup, FormBuilder, Validators } from '@angular/forms';

import { EnvironmentService } from '../../../services/environment.service';
import { ResidenceService } from '../../../services/residence.service';
import { DeviceService } from '../../../services/device.service';
import { FormUtility } from '../../utility/form-utility';
import { macValidator } from '../../validators/global.validators';

import { ACLFormComponent } from '../acl-form/acl-form.component';
import { NetworkConfigFormComponent } from '../../components/network-config-form/network-config-form.component';

@Component({
    selector: 'device-form',
    templateUrl: './device-form.component.html'
})
export class DeviceFormComponent implements OnInit {

    @ViewChild(ACLFormComponent)
    public aclForm: ACLFormComponent;
    @ViewChild(NetworkConfigFormComponent)
    public networkConfigForm: NetworkConfigFormComponent;

    @Input()
    public group: FormGroup;
    public states: String[];
    public types: Object[];

    public environments: Object[];
    public residences: Object[];
    private residence: Object;
    public rooms: Object[];

    private onValueChanged: Function;
    public formErrors = {};

    private validationMessages = {
        mac: {
            required: 'mac address is required',
            mac: 'Ivalid mac address'
        }
    };

    constructor(
        private fb: FormBuilder,
        private deviceService: DeviceService,
        private environmentService: EnvironmentService,
        private residenceService: ResidenceService
    ) { }

    ngOnInit() {
        this.deviceService.getDeviceStates().subscribe(states => {
            this.states = states;
        });
        this.deviceService.getDeviceTypes().subscribe(types => {
            this.types = types;
        });
        this.environmentService.getEnvironmentList().subscribe(envs => {
            this.environments = envs;
        });
        this.onValueChanged = FormUtility.valueChangeGenerator(this.group, this.formErrors, this.validationMessages);
        this.group.valueChanges.subscribe(data => {
            this.onValueChanged(data);
        });

        this.group.controls['environment'].valueChanges.subscribe(data => {
            this.residenceService.getResidencesInEnvironmentList(data).subscribe(res => {
                this.group.controls['residence'].enable();
                this.residences = res;
                // add empty residence to make it possible only select environment
                let emptyRes = {name: '-', guid: ''};
                this.residences.push(emptyRes);
            });
        });
        this.group.controls['residence'].valueChanges.subscribe(data => {
            this.residenceService.getResidence(data).subscribe(res => {
                this.group.controls['room'].enable();
                this.residence = res;
                this.rooms = res.room;
                if (this.rooms) {
                    // add empty room to make it possible only select residence and environment
                    this.rooms.push({comment: '-', guid: ''});
                }
            });
        });
    }

    build() {
        return this.fb.group({
            mac: [
                '', [
                    Validators.required,
                    macValidator
                ]
            ],
            status: [
                '', []
            ],
            type: [
                '', []
            ],
            network: this.fb.group({}),
            environment: [
                '', [

                    Validators.required
                ],
            ],
            residence: [
                {
                    value: '',
                    disabled: true
                }, [
                ]
            ],
            room: [
                {
                    value: '',
                    disabled: true
                }, [
                ]
            ],
            acl: this.fb.group({})
        });
    }

    sync(device) {
        // encode befor getting group.value because encode is working on the formdata itself
        // encode permissions rwx => 1-7 and remove entries with permission === 0
        this.aclForm.sync(device);

        // handle parent_path
        let formdata = this.group.value;
        device.parent_path = ',' + formdata.environment + ',' + formdata.residence + ',' + formdata.room + ',';
    }

    patchValue(entity) {
        this.aclForm.prepareACL(entity.acl);
        // get parent_path objects
        if (entity.parent_path && entity.parent_path !== '') {
            let chunks = entity.parent_path.split(',');
            if (chunks.length === 5) {
                entity.environment = chunks[1];
                entity.residence = chunks[2];
                entity.room = chunks[3];
            }
        }
        this.group.patchValue(entity);
    }
}
3
  • Just because it's so prevalent here it is worth noting that use of the String and Object types, note the initial caps, is an extremely dangerous practice and does not do what you likely think it does. Use string and {}. The types of the capitalized variants refer to the boxed intrinsics produced by calling them as constructor functions and have problematic behavior. Using Function as a type is also highly suspect. Commented Feb 12, 2017 at 7:05
  • Thanks for the advice, i'm used to programming java and c++ so i thought String and Object were the Object versions of the primary types. What the Function type concerns, what type should it be? Because i generate the function, (it's used in other components too, but with different arguments), shouldn't it be of type Function? Or is there another way to achieve this? Commented Feb 14, 2017 at 15:31
  • Consider using Use (...args: {}[]) => {} or (...args: any[]) => any Commented Feb 14, 2017 at 16:49

1 Answer 1

0

I would use MockComponent from ng-mocks

The example code from the read me shows one way of using this. You still have to import the main component so the mocker can read it's signature.

From the readme:-


import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MockModule } from 'ng-mocks';
import { DependencyModule } from './dependency.module';
import { TestedComponent } from './tested.component';

describe('MockModule', () => {
  let fixture: ComponentFixture<TestedComponent>;
  let component: TestedComponent;

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [
        TestedComponent,
      ],
      imports: [
        MockModule(DependencyModule),
      ],
    });

    fixture = TestBed.createComponent(TestedComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('renders nothing without any error', () => {
    expect(component).toBeTruthy();
  });
});

(This question might be a duplicate; I seem to remember I learnt about ng-mocks here; but I'm not seeing the other question now )

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.