scottohara/loot

View on GitHub
src/transactions/models/transaction.test.ts

Summary

Maintainability
C
7 hrs
Test Coverage
A
100%
import type {
    BasicTransaction,
    SecurityTransaction,
    SplitTransaction,
    SplitTransactionChild,
    Subtransaction,
    SubtransferTransaction,
    Transaction,
    TransactionBatch,
    TransferrableTransaction,
} from "~/transactions/types";
import {
    createBasicTransaction,
    createSubtransaction,
    createSubtransferTransaction,
} from "~/mocks/transactions/factories";
import { lightFormat, parseISO, startOfDay } from "date-fns";
import type { AccountModelMock } from "~/mocks/accounts/types";
import type { CategoryModelMock } from "~/mocks/categories/types";
import type MockDependenciesProvider from "~/mocks/loot/mockdependencies";
import type { Payee } from "~/payees/types";
import type PayeeModel from "~/payees/models/payee";
import type { SecurityModelMock } from "~/mocks/securities/types";
import type TransactionModel from "~/transactions/models/transaction";
import type { WindowMock } from "~/mocks/node-modules/angular/types";
import angular from "angular";
import createAccount from "~/mocks/accounts/factories";
import createPayee from "~/mocks/payees/factories";
import createSecurity from "~/mocks/securities/factories";
import sinon from "sinon";

describe("transactionModel", (): void => {
    let transactionModel: TransactionModel,
        $httpBackend: angular.IHttpBackendService,
        $window: WindowMock,
        accountModel: AccountModelMock,
        payeeModel: PayeeModel,
        categoryModel: CategoryModelMock,
        securityModel: SecurityModelMock;

    // Load the modules
    beforeEach(
        angular.mock.module(
            "lootMocks",
            "lootTransactions",
            (mockDependenciesProvider: MockDependenciesProvider): void =>
                mockDependenciesProvider.load([
                    "$window",
                    "accountModel",
                    "payeeModel",
                    "categoryModel",
                    "securityModel",
                ]),
        ) as Mocha.HookFunction,
    );

    // Inject the object under test and it's remaining dependencies
    beforeEach(
        angular.mock.inject(
            (
                _transactionModel_: TransactionModel,
                _$httpBackend_: angular.IHttpBackendService,
                _$window_: WindowMock,
                _accountModel_: AccountModelMock,
                _payeeModel_: PayeeModel,
                _categoryModel_: CategoryModelMock,
                _securityModel_: SecurityModelMock,
            ): void => {
                transactionModel = _transactionModel_;

                $httpBackend = _$httpBackend_;
                $window = _$window_;

                accountModel = _accountModel_;
                payeeModel = _payeeModel_;
                categoryModel = _categoryModel_;
                securityModel = _securityModel_;
            },
        ) as Mocha.HookFunction,
    );

    // After each spec, verify that there are no outstanding http expectations or requests
    afterEach((): void => {
        $httpBackend.verifyNoOutstandingExpectation();
        $httpBackend.verifyNoOutstandingRequest();
    });

    describe("path", (): void => {
        it("should return the transactions collection path when an id is not provided", (): Chai.Assertion =>
            expect(transactionModel.path()).to.equal("/transactions"));

        it("should return a specific transaction path when an id is provided", (): Chai.Assertion =>
            expect(transactionModel.path(123)).to.equal("/transactions/123"));
    });

    describe("fullPath", (): void => {
        it("should return the path including the parent context", (): void => {
            sinon.stub(transactionModel, "path").returns("/path");
            expect(transactionModel.fullPath("/context")).to.equal("/context/path");
        });
    });

    describe("parse", (): void => {
        it("should convert the transaction date from a string to a date", (): void => {
            const transaction = transactionModel["parse"](
                createBasicTransaction({
                    transaction_date: lightFormat(new Date(), "yyyy-MM-dd HH:mm:ss"),
                }),
            );

            expect(transaction.transaction_date as Date).to.be.a("date");
            expect(transaction.transaction_date as Date).to.deep.equal(
                startOfDay(new Date()),
            );
        });

        it("should do nothing if the transaction date is undefined", (): void => {
            const transaction = transactionModel["parse"](
                createBasicTransaction({ transaction_date: undefined }),
            );

            expect(transaction.transaction_date).to.be.undefined;
        });
    });

    describe("stringify", (): void => {
        it("should convert the transaction date from a date to a string", (): void => {
            const transaction = transactionModel["stringify"](
                createBasicTransaction({ transaction_date: startOfDay(new Date()) }),
            );

            expect(transaction.transaction_date as string).to.be.a("string");
            expect(transaction.transaction_date as string).to.deep.equal(
                lightFormat(new Date(), "yyyy-MM-dd"),
            );
        });

        it("should do nothing if the transaction date is undefined", (): void => {
            const transaction = transactionModel["stringify"](
                createBasicTransaction({ transaction_date: undefined }),
            );

            expect(transaction.transaction_date).to.be.undefined;
        });
    });

    describe("all", (): void => {
        const expectedResponse: TransactionBatch = {
            transactions: [createBasicTransaction(), createBasicTransaction()],
            openingBalance: 0,
            atEnd: false,
        };
        let fromDate: Date;

        beforeEach((): void => {
            fromDate = new Date();
            transactionModel["parse"] = sinon.stub().returnsArg(0);
            $httpBackend
                .expectGET(
                    new RegExp(
                        `context/transactions\\?as_at=${fromDate.toISOString()}&direction=prev&unreconciled=true`,
                        "u",
                    ),
                )
                .respond(200, expectedResponse);
        });

        it("should dispatch a GET request to /{context}/transactions?as_at={fromDate}&direction={direction}&unreconciled={unreconciledOnly}", (): void => {
            transactionModel.all("context", fromDate, "prev", true);
            $httpBackend.flush();
        });

        it("should parse each transaction returned", (): void => {
            transactionModel.all("context", fromDate, "prev", true);
            $httpBackend.flush();
            expect(transactionModel["parse"]).to.have.been.calledTwice;
        });

        it("should return a list of transactions", (): void => {
            transactionModel
                .all("context", fromDate, "prev", true)
                .then(
                    (transactionBatch: TransactionBatch): Chai.Assertion =>
                        expect(transactionBatch).to.deep.equal(expectedResponse),
                );
            $httpBackend.flush();
        });
    });

    describe("query", (): void => {
        const expectedResponse: TransactionBatch = {
            transactions: [createBasicTransaction(), createBasicTransaction()],
            openingBalance: 0,
            atEnd: false,
        };

        beforeEach((): void => {
            transactionModel["parse"] = sinon.stub().returnsArg(0);
            $httpBackend
                .expectGET(/transactions\?direction=prev&query=query/u)
                .respond(200, expectedResponse);
        });

        it("should dispatch a GET request to /transactions?direction={direction}&query={query}", (): void => {
            transactionModel.query("query", null, "prev");
            $httpBackend.flush();
        });

        it("should parse each transaction returned", (): void => {
            transactionModel.query("query", null, "prev");
            $httpBackend.flush();
            expect(transactionModel["parse"]).to.have.been.calledTwice;
        });

        it("should return a list of transactions", (): void => {
            transactionModel
                .query("query", null, "prev")
                .then(
                    (transactionBatch: TransactionBatch): Chai.Assertion =>
                        expect(transactionBatch).to.deep.equal(expectedResponse),
                );
            $httpBackend.flush();
        });
    });

    describe("findSubtransactions", (): void => {
        const expectedResponse = "subtransactions";

        beforeEach(
            (): angular.mock.IRequestHandler =>
                $httpBackend
                    .expectGET(/transactions\/123\/subtransactions/u)
                    .respond(200, expectedResponse),
        );

        it("should dispatch a GET request to /transactions/123/subtransactions", (): void => {
            transactionModel.findSubtransactions(123);
            $httpBackend.flush();
        });

        it("should return a list of subtransactions", (): void => {
            transactionModel
                .findSubtransactions(123)
                .then(
                    (subtransactions: SplitTransactionChild[]): Chai.Assertion =>
                        expect(subtransactions).to.equal(expectedResponse),
                );
            $httpBackend.flush();
        });
    });

    describe("find", (): void => {
        const expectedResponse = "transaction";

        beforeEach((): void => {
            transactionModel["parse"] = sinon.stub().returnsArg(0);
            $httpBackend
                .expectGET(/transactions\/123/u)
                .respond(200, expectedResponse);
        });

        it("should dispatch a GET request to /transactions/123", (): void => {
            transactionModel.find(123);
            $httpBackend.flush();
        });

        it("should parse the transaction", (): void => {
            transactionModel.find(123);
            $httpBackend.flush();
            expect(transactionModel["parse"]).to.have.been.calledWith(
                expectedResponse,
            );
        });

        it("should return the transaction", (): void => {
            transactionModel
                .find(123)
                .then(
                    (transaction: Transaction): Chai.Assertion =>
                        expect(transaction).to.equal(expectedResponse),
                );
            $httpBackend.flush();
        });
    });

    describe("save", (): void => {
        const expectedResponse = "transaction",
            expectedPostUrl = /transactions$/u,
            expectedPatchUrl = /transactions\/1$/u;
        let transaction: Transaction;

        beforeEach((): void => {
            transactionModel["invalidateCaches"] = sinon.stub();
            transactionModel["stringify"] = sinon.stub().returnsArg(0);
            transactionModel["parse"] = sinon.stub().returnsArg(0);
            $httpBackend.whenPOST(expectedPostUrl).respond(200, expectedResponse);
            $httpBackend.whenPATCH(expectedPatchUrl).respond(200, expectedResponse);
            transaction = createBasicTransaction({ id: 1 });
        });

        it("should invalidate the associated $http caches", (): void => {
            transactionModel.save(transaction);
            expect(transactionModel["invalidateCaches"]).to.have.been.called;
            $httpBackend.flush();
        });

        it("should stringify the transaction", (): void => {
            transactionModel.save(transaction);
            expect(transactionModel["stringify"]).to.have.been.calledWith(
                transaction,
            );
            $httpBackend.flush();
        });

        it("should dispatch a POST request to /transactions when an id is not provided", (): void => {
            transaction.id = null;
            $httpBackend.expectPOST(expectedPostUrl, transaction);
            transactionModel.save(transaction);
            $httpBackend.flush();
        });

        it("should dispatch a PATCH request to /transactions/{id} when an id is provided", (): void => {
            $httpBackend.expectPATCH(expectedPatchUrl, transaction);
            transactionModel.save(transaction);
            $httpBackend.flush();
        });

        it("should save the transaction date", (): void => {
            transaction.transaction_date = parseISO("2000-01-01");
            transactionModel.save(transaction);
            $httpBackend.flush();
            expect(transactionModel.lastTransactionDate as Date).to.deep.equal(
                transaction.transaction_date,
            );
        });

        it("should parse the transaction", (): void => {
            transactionModel.save(transaction);
            $httpBackend.flush();
            expect(transactionModel["parse"]).to.have.been.calledWith(
                expectedResponse,
            );
        });

        it("should return the transaction", (): void => {
            transactionModel
                .save(transaction)
                .then(
                    (savedTransaction: Transaction): Chai.Assertion =>
                        expect(savedTransaction).to.equal(expectedResponse),
                );
            $httpBackend.flush();
        });
    });

    describe("destroy", (): void => {
        const expectedUrl = /transactions\/1$/u;
        let transaction: Transaction;

        beforeEach((): void => {
            transactionModel["invalidateCaches"] = sinon.stub();
            $httpBackend.whenDELETE(expectedUrl).respond(200);
            transaction = createBasicTransaction({ id: 1 });
        });

        it("should invalidate the associated $http caches", (): void => {
            transactionModel.destroy(transaction);
            expect(transactionModel["invalidateCaches"]).to.have.been.called;
            $httpBackend.flush();
        });

        it("should dispatch a DELETE request to /transactions/{id}", (): void => {
            $httpBackend.expectDELETE(expectedUrl);
            transactionModel.destroy(transaction);
            $httpBackend.flush();
        });
    });

    describe("invalidateCaches", (): void => {
        const transaction: Transaction = createBasicTransaction() as Transaction;

        beforeEach((): void => {
            (transaction as TransferrableTransaction).account = createAccount();
            (transaction as SecurityTransaction).security = createSecurity();
            (transaction as SplitTransaction).subtransactions = [
                createSubtransaction(),
                createSubtransferTransaction(),
            ];
            transactionModel["invalidateCache"] = sinon.stub();
            transactionModel["invalidateCaches"](transaction);
        });

        it("should invalidate the primary account from the account cache", (): Chai.Assertion =>
            expect(transactionModel["invalidateCache"]).to.have.been.calledWith(
                accountModel,
                transaction.primary_account,
            ));

        it("should invalidate the payee from the payee cache", (): Chai.Assertion =>
            expect(transactionModel["invalidateCache"]).to.have.been.calledWith(
                payeeModel,
                (transaction as BasicTransaction).payee,
            ));

        it("should invalidate the category from the category cache", (): Chai.Assertion =>
            expect(transactionModel["invalidateCache"]).to.have.been.calledWith(
                categoryModel,
                transaction.category,
            ));

        it("should invalidate the subcategory from the category cache", (): Chai.Assertion =>
            expect(transactionModel["invalidateCache"]).to.have.been.calledWith(
                categoryModel,
                (transaction as BasicTransaction).subcategory,
            ));

        it("should invalidate the account from the account cache", (): Chai.Assertion =>
            expect(transactionModel["invalidateCache"]).to.have.been.calledWith(
                accountModel,
                (transaction as TransferrableTransaction).account,
            ));

        it("should invalidate the security from the security cache", (): Chai.Assertion =>
            expect(transactionModel["invalidateCache"]).to.have.been.calledWith(
                securityModel,
                (transaction as SecurityTransaction).security,
            ));

        it("should invalidate any subtransaction categories from the category cache", (): Chai.Assertion =>
            expect(transactionModel["invalidateCache"]).to.have.been.calledWith(
                categoryModel,
                (transaction as SplitTransaction).subtransactions[0].category,
            ));

        it("should invalidate any subtransaction subcategories from the category cache", (): Chai.Assertion =>
            expect(transactionModel["invalidateCache"]).to.have.been.calledWith(
                categoryModel,
                ((transaction as SplitTransaction).subtransactions[0] as Subtransaction)
                    .subcategory,
            ));

        it("should invalidate any subtransfer accounts from the account cache", (): Chai.Assertion =>
            expect(transactionModel["invalidateCache"]).to.have.been.calledWith(
                accountModel,
                (
                    (transaction as SplitTransaction)
                        .subtransactions[1] as SubtransferTransaction
                ).account,
            ));
    });

    describe("invalidateCache", (): void => {
        it("should do nothing if the item is an empty string", (): void => {
            transactionModel["invalidateCache"](payeeModel, "");
            expect(payeeModel["flush"]).to.not.have.been.called;
        });

        it("should flush the $http cache if the item is a non-empty string", (): void => {
            transactionModel["invalidateCache"](payeeModel, "test");
            expect(payeeModel["flush"]).to.have.been.calledWithExactly();
        });

        it("should do nothing if the item is undefined", (): void => {
            transactionModel["invalidateCache"](payeeModel, {} as Payee);
            expect(payeeModel["flush"]).to.not.have.been.called;
        });

        it("should do nothing if the item is null", (): void => {
            transactionModel["invalidateCache"](payeeModel, null);
            expect(payeeModel["flush"]).to.not.have.been.called;
        });

        it("should do nothing if the item has no id", (): void => {
            const payee = createPayee();

            delete payee.id;
            transactionModel["invalidateCache"](payeeModel, payee);
            expect(payeeModel["flush"]).to.not.have.been.called;
        });

        it("should remove the item from the $http cache when the item has an id", (): void => {
            transactionModel["invalidateCache"](payeeModel, createPayee({ id: 1 }));
            expect(payeeModel["flush"]).to.have.been.calledWith(1);
        });
    });

    describe("updateStatus", (): void => {
        const expectedPatchUrl = /context\/transactions\/1\/status\?Cleared$/u,
            expectedDeleteUrl = /context\/transactions\/1\/status$/u;

        beforeEach((): void => {
            $httpBackend.whenPATCH(expectedPatchUrl).respond(200);
            $httpBackend.whenDELETE(expectedDeleteUrl).respond(200);
        });

        it("should dispatch a PATCH request to /{context}/transactions/{id}/status?{status} when a status is provided", (): void => {
            $httpBackend.expectPATCH(expectedPatchUrl);
            transactionModel.updateStatus("context", 1, "Cleared");
        });

        it("should dispatch a DELETE request to /{context}/transactions/{id}/status when a blank status is provided", (): void => {
            $httpBackend.expectDELETE(expectedDeleteUrl);
            transactionModel.updateStatus("context", 1, "");
        });

        it("should dispatch a DELETE request to /{context}/transactions/{id}/status when a null status is provided", (): void => {
            $httpBackend.expectDELETE(expectedDeleteUrl);
            transactionModel.updateStatus("context", 1);
        });

        it("should dispatch a DELETE request to /{context}/transactions/{id}/status when a status is not provided", (): void => {
            $httpBackend.expectDELETE(expectedDeleteUrl);
            transactionModel.updateStatus("context", 1);
        });

        afterEach((): void => $httpBackend.flush());
    });

    describe("flag", (): void => {
        it("should dispatch a PUT request to /transactions/{id}/flag", (): void => {
            $httpBackend
                .expectPUT(/transactions\/1\/flag/u, {
                    flag_type: "noreceipt",
                    memo: "flag",
                })
                .respond(200);
            transactionModel.flag(
                createBasicTransaction({ id: 1, flag_type: "noreceipt", flag: "flag" }),
            );
            $httpBackend.flush();
        });
    });

    describe("unflag", (): void => {
        it("should dispatch a DELETE request to /transactions/{id}/flag", (): void => {
            $httpBackend.expectDELETE(/transactions\/1\/flag/u).respond(200);
            transactionModel.unflag(1);
            $httpBackend.flush();
        });
    });

    describe("allDetailsShown", (): void => {
        it("should be true if the show all details setting is not present", (): Chai.Assertion =>
            expect(transactionModel.allDetailsShown()).to.be.true);

        it("should be true if the show all details setting is not set to false", (): void => {
            $window.localStorage.getItem
                .withArgs("lootShowAllTransactionDetails")
                .returns("true");
            expect(transactionModel.allDetailsShown()).to.be.true;
        });

        it("should be false if the show all details setting is set to false", (): void => {
            $window.localStorage.getItem
                .withArgs("lootShowAllTransactionDetails")
                .returns("false");
            expect(transactionModel.allDetailsShown()).to.be.false;
        });
    });

    describe("showAllDetails", (): void => {
        it("should save the show all details setting", (): void => {
            transactionModel.showAllDetails(true);
            expect($window.localStorage.setItem).to.have.been.calledWith(
                "lootShowAllTransactionDetails",
                "true",
            );
        });
    });

    describe("lastTransactionDate", (): void => {
        it("should return the last used transaction date", (): Chai.Assertion =>
            expect(transactionModel.lastTransactionDate as Date).to.deep.equal(
                startOfDay(new Date()),
            ));
    });
});