MatthiasKainer/lit-element-state-decoupler

View on GitHub
workflow.test.ts

Summary

Maintainability
D
2 days
Test Coverage
import { Reducer } from ".";
import { LitLikeElement, Workflow } from "./types"
import { useWorkflow } from "./workflow";

type Customer = {
    userName: string
}

type Contract = {
    contractNumber: number,
    userName: string
    created: Date
    ends: Date
}

type ErrorCodes = "NOT_FOUND" | "CONFLICT"
class Rejection<T> {
    code: ErrorCodes
    message: string
    state: T

    constructor(code: ErrorCodes, message: string, state: T) {
        this.code = code;
        this.message = message
        this.state = state
    }

    public toString() {
        return `[${this.code}] ${this.message}`;
    }
}

const notFound = <T>(who: string, state: T) => (console.log(`${who} not found`, state), new Rejection("NOT_FOUND", `${who} not found`, state));
const conflict = <T>(who: string, state: T) => (console.log(`${who} already exists`, state), new Rejection("CONFLICT", `${who} already exists`, state));

const customersReducer: Reducer<Customer[]> = (state: Customer[]) => ({
    createCustomer: (userName: string) => {
        return new Promise((resolve, reject) => {
            if (state.some(customer => customer.userName === userName)) return reject(conflict("User", state));
            return resolve([...state, { userName }] as Customer[])
        });
    },
    changeCustomerName: ({ oldName, userName }: { oldName: string, userName: string }) => new Promise((resolve, reject) => {
        const index = state.findIndex((customer) => customer.userName === oldName);
        if (index < 0) return reject(notFound("User", state));
        state[index] = { ...state[index], userName }
        return resolve([...state] as Customer[])
    }),
    deleteCustomer: (userName: string) => new Promise((resolve, reject) => {
        const index = state.findIndex((customer) => customer.userName === userName);
        if (index < 0) return (reject(notFound("User", state)));
        state.splice(index, 1)
        return resolve([...state] as Customer[])
    })
})

const contractReducer: Reducer<Contract[]> = (state: Contract[]) => ({
    createContract: ({ contractNumber, userName, runtime }: { contractNumber: number, userName: string, runtime: number }) => new Promise((resolve, reject) => {
        if (state.some(contract => contract.contractNumber === contractNumber)) return reject(conflict("Contract", state));
        const created = new Date(Date.now())
        // contract always starts at the beginning of the month
        created.setDate(1)
        const ends = new Date(created)
        ends.setMonth(ends.getMonth() + runtime)
        return resolve([
            ...state,
            { contractNumber, userName: userName, created, ends }
        ])
    }),
    changeCustomerName: ({ oldName, userName }: { oldName: string, userName: string }) => new Promise((resolve) =>
        resolve(state.map(contract =>
            (contract.userName === oldName) ? { ...contract, userName } : contract
        ))
    ),
    removeContract: (contractNumber: number) => new Promise((resolve, reject) => {
        const index = state.findIndex(contract => contract.contractNumber === contractNumber);
        if (index < 0) return reject(notFound("Contract", state));
        state.splice(index, 1)
        return resolve([...state])
    })
})

describe("Given I have a workflow", () => {

    let workflow: Workflow;


    beforeEach(() => {
        jest.resetAllMocks()
        const litElement = {
            requestUpdate: jest.fn(),
            dispatchEvent: jest.fn()
        } as unknown as LitLikeElement
        workflow = useWorkflow(litElement, {
            customers: { reducer: customersReducer, initialState: [] },
            contracts: { reducer: contractReducer, initialState: [] }
        })
    })

    it("returns undefined for non-existing projections (probably a bad idea, let's think about something better)", () => {
        expect(workflow.projections("made up")).toEqual(undefined)
    })

    it("executes every step when all go well", async () => {
        let userName = "Mustermann"
        await workflow.addActivity("createCustomer", userName);
        expect(workflow.projections("customers")).toEqual([{ userName }])
        expect(workflow.projections("contracts")).toEqual([])

        const contractNumber = 1
        await workflow.addActivity("createContract", { userName, contractNumber, runtime: 3 });
        expect(workflow.projections("customers")).toEqual([{ userName }])
        expect(workflow.projections("contracts")).toEqual([{ contractNumber, "created": expect.any(Date), "ends": expect.any(Date), userName }])

        userName = "Klaus"
        await workflow.addActivity("changeCustomerName", { oldName: "Mustermann", userName })
        expect(workflow.projections("customers")).toEqual([{ userName }])
        expect(workflow.projections("contracts")).toEqual([{ contractNumber, "created": expect.any(Date), "ends": expect.any(Date), userName }])

        workflow.addSideeffect("deleteCustomer", async (userName) =>
            await Promise.all((workflow.projections("contracts") as Contract[])
                .filter(contract => contract.userName === userName)
                .map(({ contractNumber }) => workflow.addActivity("removeContract", contractNumber)))
        )
        await workflow.addActivity("deleteCustomer", userName)
        expect(workflow.projections("customers")).toEqual([])
        expect(workflow.projections("contracts")).toEqual([])

        expect(workflow.history()).toMatchSnapshot()
    })

    it("compensates if a step fails", async () => {
        let userName = "Mustermann"
        const contractNumber = 1
        await workflow.addActivity("createCustomer", userName);
        workflow.addCompensation("deleteCustomer", userName);
        await workflow.addActivity("createContract", { userName, contractNumber, runtime: 3 });
        workflow.addCompensation("removeContract", contractNumber);

        expect(workflow.projections("customers")).toEqual([{ userName }])
        expect(workflow.projections("contracts")).toEqual([{ contractNumber, "created": expect.any(Date), "ends": expect.any(Date), userName }])

        // we just want to avoid it gets triggered right away, just for readability
        const days = (_: number) => {
            const future = new Date();
            future.setMilliseconds(future.getMilliseconds() + 100);
            return future
        }

        workflow.after(days(1), { type: "confirmedEmail", args: [userName] }, async () => await workflow.compensate())
        await new Promise(resolve => setTimeout(resolve, 500))

        // compensation should have happenend
        expect(workflow.projections("customers")).toEqual([])
        expect(workflow.projections("contracts")).toEqual([])
        expect(workflow.history().some(entry => entry.type === "compensate")).toBeTruthy()

    })

    it("does not compensates if all step pass on a full history match", async () => {
        let userName = "Mustermann"
        const contractNumber = 1
        await workflow.addActivity("createCustomer", userName);
        workflow.addCompensation("deleteCustomer", userName);
        await workflow.addActivity("createContract", { userName, contractNumber, runtime: 3 });
        workflow.addCompensation("removeContract", contractNumber);

        expect(workflow.projections("customers")).toEqual([{ userName }])
        expect(workflow.projections("contracts")).toEqual([{ contractNumber, "created": expect.any(Date), "ends": expect.any(Date), userName }])

        // we want that to be now anyways, just for readability
        const days = (_: number) => new Date(0);

        // it should not matter if an activity has happened before or after in a workflow
        //  this way we won't have timing issues as after will be executed right away
        await workflow.addActivity("confirmedEmail", userName);

        workflow.after(days(1), { type: "addActivity", args: ["confirmedEmail", userName] }, async () => await workflow.compensate())

        // should not be componsated
        expect(workflow.projections("customers")).toEqual([{ userName }])
        expect(workflow.projections("contracts")).toEqual([{ contractNumber, "created": expect.any(Date), "ends": expect.any(Date), userName }])
        expect(workflow.history().some(entry => entry.type === "compensate")).toBeFalsy()

    })
    it("does not compensates if all step pass when checking for the start of a history entry", async () => {
        let userName = "Mustermann"
        const contractNumber = 1
        await workflow.addActivity("createCustomer", userName);
        workflow.addCompensation("deleteCustomer", userName);
        await workflow.addActivity("createContract", { userName, contractNumber, runtime: 3 });
        workflow.addCompensation("removeContract", contractNumber);

        expect(workflow.projections("customers")).toEqual([{ userName }])
        expect(workflow.projections("contracts")).toEqual([{ contractNumber, "created": expect.any(Date), "ends": expect.any(Date), userName }])

        // we want that to be now anyways, just for readability
        const days = (_: number) => new Date(0);

        // it should not matter if an activity has happened before or after in a workflow
        //  this way we won't have timing issues as after will be executed right away
        await workflow.addActivity("confirmedEmail", userName);

        workflow.after(days(1), { type: "addActivity", args: ["confirmedEmail"] }, async () => await workflow.compensate())

        // should not be componsated
        expect(workflow.projections("customers")).toEqual([{ userName }])
        expect(workflow.projections("contracts")).toEqual([{ contractNumber, "created": expect.any(Date), "ends": expect.any(Date), userName }])
        expect(workflow.history().some(entry => entry.type === "compensate")).toBeFalsy()

    })
})

describe("Given I plan a workflow", () => {
    let workflow: Workflow;


    beforeEach(() => {
        jest.resetAllMocks()
        const litElement = {
            requestUpdate: jest.fn(),
            dispatchEvent: jest.fn()
        } as unknown as LitLikeElement
        workflow = useWorkflow(litElement, {
            customers: { reducer: customersReducer, initialState: [] },
            contracts: { reducer: contractReducer, initialState: [] }
        })
    })

    it("executes the plan in the correct order", async () => {
        const mockTrace = jest.fn();
        const userName = "name"
        const contractNumber = 1
        const plan = {
            customers: async () => {
                await workflow.addActivity("createCustomer", userName);
                mockTrace("customer")
                return "customer"
            },
            contracts: async () => {
                await workflow.addActivity("createContract", { userName, contractNumber, runtime: 3 });
                mockTrace("contracts")
                return "contracts"
            },
            "" : async () => {
                mockTrace("done")
                return "done"
            }
        }
        let result = await workflow.plan(plan)

        await new Promise(process.nextTick)

        expect(mockTrace.mock.calls[0][0]).toBe("customer")
        result = await workflow.plan(plan)
        expect(mockTrace.mock.calls[1][0]).toBe("contracts")
        result = await workflow.plan(plan)
        expect(result).toBe("done")
    })

    it("finishes a plan with an empty last result if none given", async () => {
        const mockTrace = jest.fn();
        const plan = {
            customers: async () => {
                await workflow.addActivity("createCustomer", "name");
                mockTrace("customer")
                return "customer"
            },
            madeUp: async () => "made up!"
        }
        let result = await workflow.plan(plan)

        await new Promise(process.nextTick)

        expect(mockTrace.mock.calls[0][0]).toBe("customer")
        result = await workflow.plan(plan)
        expect(result).toBe(null)
    })
})