# The Mia! Accounting Project. # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1 # Copyright (c) 2023 imacat. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """The test for the account management. """ import datetime as dt import unittest import httpx from flask import Flask from accounting.utils.next_uri import encode_next from test_site import db from testlib import NEXT_URI, create_test_app, get_client, get_csrf_token, \ set_locale, add_journal_entry class AccountData: """The account data.""" def __init__(self, base_code: str, no: int, title: str): """Constructs the account data. :param base_code: The base code. :param no: The number. :param title: The title. """ self.base_code: str = base_code """The base code.""" self.no: int = no """The number.""" self.title: str = title """The title.""" self.code: str = f"{self.base_code}-{self.no:03d}" """The code.""" CASH: AccountData = AccountData("1111", 1, "Cash") """The cash account.""" PETTY: AccountData = AccountData("1112", 1, "Bank") """The petty cash account.""" BANK: AccountData = AccountData("1113", 1, "Bank") """The bank account.""" STOCK: AccountData = AccountData("1121", 1, "Stock") """The stock account.""" LOAN: AccountData = AccountData("2112", 1, "Loan") """The loan account.""" PREFIX: str = "/accounting/accounts" """The URL prefix for the account management.""" class AccountTestCase(unittest.TestCase): """The account test case.""" def setUp(self) -> None: """Sets up the test. This is run once per test. :return: None. """ self.__app: Flask = create_test_app() """The Flask application.""" with self.__app.app_context(): from accounting.models import Account, AccountL10n AccountL10n.query.delete() Account.query.delete() db.session.commit() self.__encoded_next_uri: str = encode_next(NEXT_URI) """The encoded next URI.""" self.__client: httpx.Client = get_client(self.__app, "editor") """The user client.""" self.__csrf_token: str = get_csrf_token(self.__client) """The CSRF token.""" response: httpx.Response response = self.__client.post(f"{PREFIX}/store", data={"csrf_token": self.__csrf_token, "base_code": CASH.base_code, "title": CASH.title}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], f"{PREFIX}/{CASH.code}") response = self.__client.post(f"{PREFIX}/store", data={"csrf_token": self.__csrf_token, "base_code": BANK.base_code, "title": BANK.title}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], f"{PREFIX}/{BANK.code}") def test_nobody(self) -> None: """Test the permission as nobody. :return: None. """ from accounting.models import Account client: httpx.Client = get_client(self.__app, "nobody") csrf_token: str = get_csrf_token(client) response: httpx.Response response = client.get(PREFIX) self.assertEqual(response.status_code, 403) response = client.get(f"{PREFIX}/{CASH.code}") self.assertEqual(response.status_code, 403) response = client.get(f"{PREFIX}/create") self.assertEqual(response.status_code, 403) response = client.post(f"{PREFIX}/store", data={"csrf_token": csrf_token, "base_code": STOCK.base_code, "title": STOCK.title}) self.assertEqual(response.status_code, 403) response = client.get(f"{PREFIX}/{CASH.code}/edit") self.assertEqual(response.status_code, 403) response = client.post(f"{PREFIX}/{CASH.code}/update", data={"csrf_token": csrf_token, "base_code": CASH.base_code, "title": f"{CASH.title}-2"}) self.assertEqual(response.status_code, 403) response = client.post(f"{PREFIX}/{BANK.code}/delete", data={"csrf_token": csrf_token}) self.assertEqual(response.status_code, 403) response = client.get(f"{PREFIX}/bases/{CASH.base_code}") self.assertEqual(response.status_code, 403) with self.__app.app_context(): cash_id: int = Account.find_by_code(CASH.code).id response = client.post(f"{PREFIX}/bases/{CASH.base_code}", data={"csrf_token": csrf_token, "next": self.__encoded_next_uri, f"{cash_id}-no": "5"}) self.assertEqual(response.status_code, 403) def test_viewer(self) -> None: """Test the permission as viewer. :return: None. """ from accounting.models import Account client: httpx.Client = get_client(self.__app, "viewer") csrf_token: str = get_csrf_token(client) response: httpx.Response response = client.get(PREFIX) self.assertEqual(response.status_code, 200) response = client.get(f"{PREFIX}/{CASH.code}") self.assertEqual(response.status_code, 200) response = client.get(f"{PREFIX}/create") self.assertEqual(response.status_code, 403) response = client.post(f"{PREFIX}/store", data={"csrf_token": csrf_token, "base_code": STOCK.base_code, "title": STOCK.title}) self.assertEqual(response.status_code, 403) response = client.get(f"{PREFIX}/{CASH.code}/edit") self.assertEqual(response.status_code, 403) response = client.post(f"{PREFIX}/{CASH.code}/update", data={"csrf_token": csrf_token, "base_code": CASH.base_code, "title": f"{CASH.title}-2"}) self.assertEqual(response.status_code, 403) response = client.post(f"{PREFIX}/{BANK.code}/delete", data={"csrf_token": csrf_token}) self.assertEqual(response.status_code, 403) response = client.get(f"{PREFIX}/bases/{CASH.base_code}") self.assertEqual(response.status_code, 200) with self.__app.app_context(): cash_id: int = Account.find_by_code(CASH.code).id response = client.post(f"{PREFIX}/bases/{CASH.base_code}", data={"csrf_token": csrf_token, "next": self.__encoded_next_uri, f"{cash_id}-no": "5"}) self.assertEqual(response.status_code, 403) def test_editor(self) -> None: """Test the permission as editor. :return: None. """ from accounting.models import Account response: httpx.Response response = self.__client.get(PREFIX) self.assertEqual(response.status_code, 200) response = self.__client.get(f"{PREFIX}/{CASH.code}") self.assertEqual(response.status_code, 200) response = self.__client.get(f"{PREFIX}/create") self.assertEqual(response.status_code, 200) response = self.__client.post(f"{PREFIX}/store", data={"csrf_token": self.__csrf_token, "base_code": STOCK.base_code, "title": STOCK.title}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], f"{PREFIX}/{STOCK.code}") response = self.__client.get(f"{PREFIX}/{CASH.code}/edit") self.assertEqual(response.status_code, 200) response = self.__client.post(f"{PREFIX}/{CASH.code}/update", data={"csrf_token": self.__csrf_token, "base_code": CASH.base_code, "title": f"{CASH.title}-2"}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], f"{PREFIX}/{CASH.code}") response = self.__client.post(f"{PREFIX}/{BANK.code}/delete", data={"csrf_token": self.__csrf_token}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], PREFIX) response = self.__client.get(f"{PREFIX}/bases/{CASH.base_code}") self.assertEqual(response.status_code, 200) with self.__app.app_context(): cash_id: int = Account.find_by_code(CASH.code).id response = self.__client.post(f"{PREFIX}/bases/{CASH.base_code}", data={"csrf_token": self.__csrf_token, "next": self.__encoded_next_uri, f"{cash_id}-no": "5"}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], NEXT_URI) def test_add(self) -> None: """Tests to add the currencies. :return: None. """ from accounting.models import Account create_uri: str = f"{PREFIX}/create" store_uri: str = f"{PREFIX}/store" detail_uri: str = f"{PREFIX}/{STOCK.code}" response: httpx.Response with self.__app.app_context(): self.assertEqual({x.code for x in Account.query.all()}, {CASH.code, BANK.code}) # Missing CSRF token response = self.__client.post(store_uri, data={"base_code": STOCK.base_code, "title": STOCK.title}) self.assertEqual(response.status_code, 400) # CSRF token mismatch response = self.__client.post(store_uri, data={"csrf_token": f"{self.__csrf_token}-2", "base_code": STOCK.base_code, "title": STOCK.title}) self.assertEqual(response.status_code, 400) # Empty base account code response = self.__client.post(store_uri, data={"csrf_token": self.__csrf_token, "base_code": " ", "title": STOCK.title}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], create_uri) # Non-existing base account response = self.__client.post(store_uri, data={"csrf_token": self.__csrf_token, "base_code": "9999", "title": STOCK.title}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], create_uri) # Unavailable base account response = self.__client.post(store_uri, data={"csrf_token": self.__csrf_token, "base_code": "1", "title": STOCK.title}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], create_uri) # Empty name response = self.__client.post(store_uri, data={"csrf_token": self.__csrf_token, "base_code": STOCK.base_code, "title": " "}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], create_uri) # A nominal account that needs offset response = self.__client.post(store_uri, data={"csrf_token": self.__csrf_token, "base_code": "6172", "title": STOCK.title, "is_need_offset": "yes"}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], create_uri) # Success, with spaces to be stripped response = self.__client.post(store_uri, data={"csrf_token": self.__csrf_token, "base_code": f" {STOCK.base_code} ", "title": f" {STOCK.title} "}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], detail_uri) # Success under the same base response = self.__client.post(store_uri, data={"csrf_token": self.__csrf_token, "base_code": STOCK.base_code, "title": STOCK.title}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], f"{PREFIX}/{STOCK.base_code}-002") # Success under the same base, with order in a mess. with self.__app.app_context(): stock_2: Account = Account.find_by_code(f"{STOCK.base_code}-002") stock_2.no = 66 db.session.commit() response = self.__client.post(store_uri, data={"csrf_token": self.__csrf_token, "base_code": STOCK.base_code, "title": STOCK.title}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], f"{PREFIX}/{STOCK.base_code}-003") with self.__app.app_context(): self.assertEqual({x.code for x in Account.query.all()}, {CASH.code, BANK.code, STOCK.code, f"{STOCK.base_code}-002", f"{STOCK.base_code}-003"}) account: Account = Account.find_by_code(STOCK.code) self.assertEqual(account.base_code, STOCK.base_code) self.assertEqual(account.title_l10n, STOCK.title) def test_basic_update(self) -> None: """Tests the basic rules to update a user. :return: None. """ from accounting.models import Account detail_uri: str = f"{PREFIX}/{CASH.code}" edit_uri: str = f"{PREFIX}/{CASH.code}/edit" update_uri: str = f"{PREFIX}/{CASH.code}/update" detail_c_uri: str = f"{PREFIX}/{STOCK.code}" response: httpx.Response # Success, with spaces to be stripped response = self.__client.post(update_uri, data={"csrf_token": self.__csrf_token, "base_code": f" {CASH.base_code} ", "title": f" {CASH.title}-1 "}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], detail_uri) with self.__app.app_context(): account: Account = Account.find_by_code(CASH.code) self.assertEqual(account.base_code, CASH.base_code) self.assertEqual(account.title_l10n, f"{CASH.title}-1") # Empty base account code response = self.__client.post(update_uri, data={"csrf_token": self.__csrf_token, "base_code": " ", "title": STOCK.title}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], edit_uri) # Non-existing base account response = self.__client.post(update_uri, data={"csrf_token": self.__csrf_token, "base_code": "9999", "title": STOCK.title}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], edit_uri) # Unavailable base account response = self.__client.post(update_uri, data={"csrf_token": self.__csrf_token, "base_code": "1", "title": STOCK.title}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], edit_uri) # Empty name response = self.__client.post(update_uri, data={"csrf_token": self.__csrf_token, "base_code": STOCK.base_code, "title": " "}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], edit_uri) # A nominal account that needs offset response = self.__client.post(update_uri, data={"csrf_token": self.__csrf_token, "base_code": "6172", "title": STOCK.title, "is_need_offset": "yes"}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], edit_uri) # Change the base account response = self.__client.post(update_uri, data={"csrf_token": self.__csrf_token, "base_code": STOCK.base_code, "title": STOCK.title}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], detail_c_uri) response = self.__client.get(detail_uri) self.assertEqual(response.status_code, 404) response = self.__client.get(detail_c_uri) self.assertEqual(response.status_code, 200) def test_update_not_modified(self) -> None: """Tests that the data is not modified. :return: None. """ from accounting.models import Account detail_uri: str = f"{PREFIX}/{CASH.code}" update_uri: str = f"{PREFIX}/{CASH.code}/update" account: Account response: httpx.Response response = self.__client.post(update_uri, data={"csrf_token": self.__csrf_token, "base_code": f" {CASH.base_code} ", "title": f" {CASH.title} "}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], detail_uri) with self.__app.app_context(): account = Account.find_by_code(CASH.code) self.assertIsNotNone(account) account.created_at \ = account.created_at - dt.timedelta(seconds=5) account.updated_at = account.created_at db.session.commit() response = self.__client.post(update_uri, data={"csrf_token": self.__csrf_token, "base_code": CASH.base_code, "title": STOCK.title}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], detail_uri) with self.__app.app_context(): account = Account.find_by_code(CASH.code) self.assertIsNotNone(account) self.assertLess(account.created_at, account.updated_at) def test_created_updated_by(self) -> None: """Tests the created-by and updated-by record. :return: None. """ from accounting.models import Account editor_username, admin_username = "editor", "admin" client: httpx.Client = get_client(self.__app, admin_username) csrf_token: str = get_csrf_token(client) detail_uri: str = f"{PREFIX}/{CASH.code}" update_uri: str = f"{PREFIX}/{CASH.code}/update" account: Account response: httpx.Response with self.__app.app_context(): account = Account.find_by_code(CASH.code) self.assertEqual(account.created_by.username, editor_username) self.assertEqual(account.updated_by.username, editor_username) response = client.post(update_uri, data={"csrf_token": csrf_token, "base_code": CASH.base_code, "title": f"{CASH.title}-2"}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], detail_uri) with self.__app.app_context(): account = Account.find_by_code(CASH.code) self.assertEqual(account.created_by.username, editor_username) self.assertEqual(account.updated_by.username, admin_username) def test_l10n(self) -> None: """Tests the localization. :return: None """ from accounting.models import Account detail_uri: str = f"{PREFIX}/{CASH.code}" update_uri: str = f"{PREFIX}/{CASH.code}/update" account: Account response: httpx.Response with self.__app.app_context(): account = Account.find_by_code(CASH.code) self.assertEqual(account.title_l10n, CASH.title) self.assertEqual(account.l10n, []) set_locale(self.__app, self.__client, self.__csrf_token, "zh_Hant") response = self.__client.post(update_uri, data={"csrf_token": self.__csrf_token, "base_code": CASH.base_code, "title": f"{CASH.title}-zh_Hant"}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], detail_uri) with self.__app.app_context(): account = Account.find_by_code(CASH.code) self.assertEqual(account.title_l10n, CASH.title) self.assertEqual({(x.locale, x.title) for x in account.l10n}, {("zh_Hant", f"{CASH.title}-zh_Hant")}) set_locale(self.__app, self.__client, self.__csrf_token, "en") response = self.__client.post(update_uri, data={"csrf_token": self.__csrf_token, "base_code": CASH.base_code, "title": f"{CASH.title}-2"}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], detail_uri) with self.__app.app_context(): account = Account.find_by_code(CASH.code) self.assertEqual(account.title_l10n, f"{CASH.title}-2") self.assertEqual({(x.locale, x.title) for x in account.l10n}, {("zh_Hant", f"{CASH.title}-zh_Hant")}) set_locale(self.__app, self.__client, self.__csrf_token, "zh_Hant") response = self.__client.post(update_uri, data={"csrf_token": self.__csrf_token, "base_code": CASH.base_code, "title": f"{CASH.title}-zh_Hant-2"}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], detail_uri) with self.__app.app_context(): account = Account.find_by_code(CASH.code) self.assertEqual(account.title_l10n, f"{CASH.title}-2") self.assertEqual({(x.locale, x.title) for x in account.l10n}, {("zh_Hant", f"{CASH.title}-zh_Hant-2")}) def test_delete(self) -> None: """Tests to delete a currency. :return: None. """ from accounting.models import Account detail_uri: str = f"{PREFIX}/{PETTY.code}" delete_uri: str = f"{PREFIX}/{PETTY.code}/delete" list_uri: str = PREFIX response: httpx.Response response = self.__client.post(f"{PREFIX}/store", data={"csrf_token": self.__csrf_token, "base_code": PETTY.base_code, "title": PETTY.title}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], detail_uri) add_journal_entry(self.__client, form={"csrf_token": self.__csrf_token, "next": self.__encoded_next_uri, "date": dt.date.today().isoformat(), "currency-1-code": "USD", "currency-1-credit-1-account_code": BANK.code, "currency-1-credit-1-amount": "20"}) with self.__app.app_context(): self.assertEqual({x.code for x in Account.query.all()}, {CASH.code, PETTY.code, BANK.code}) # Cannot delete the cash account response = self.__client.post(f"{PREFIX}/{CASH.code}/delete", data={"csrf_token": self.__csrf_token}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], f"{PREFIX}/{CASH.code}") # Cannot delete the account that is in use response = self.__client.post(f"{PREFIX}/{BANK.code}/delete", data={"csrf_token": self.__csrf_token}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], f"{PREFIX}/{BANK.code}") # Success response = self.__client.get(detail_uri) self.assertEqual(response.status_code, 200) response = self.__client.post(delete_uri, data={"csrf_token": self.__csrf_token}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], list_uri) with self.__app.app_context(): self.assertEqual({x.code for x in Account.query.all()}, {CASH.code, BANK.code}) response = self.__client.get(detail_uri) self.assertEqual(response.status_code, 404) response = self.__client.post(delete_uri, data={"csrf_token": self.__csrf_token}) self.assertEqual(response.status_code, 404) def test_change_base_code(self) -> None: """Tests to change the base code of an account. :return: None. """ from accounting.models import Account response: httpx.Response for i in range(2, 6): response = self.__client.post(f"{PREFIX}/store", data={"csrf_token": self.__csrf_token, "base_code": "1111", "title": "Title"}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], f"{PREFIX}/1111-00{i}") with self.__app.app_context(): account_1: Account = Account.find_by_code("1111-001") id_1: int = account_1.id account_2: Account = Account.find_by_code("1111-002") id_2: int = account_2.id account_3: Account = Account.find_by_code("1111-003") id_3: int = account_3.id account_4: Account = Account.find_by_code("1111-004") id_4: int = account_4.id account_5: Account = Account.find_by_code("1111-005") id_5: int = account_5.id account_1.no = 3 account_2.no = 5 account_3.no = 8 account_4.base_code = "1112" account_4.no = 2 account_5.base_code = "1112" account_5.no = 6 db.session.commit() response = self.__client.post(f"{PREFIX}/1111-005/update", data={"csrf_token": self.__csrf_token, "base_code": "1112", "title": "Title"}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], f"{PREFIX}/1112-003") with self.__app.app_context(): self.assertEqual(db.session.get(Account, id_1).no, 1) self.assertEqual(db.session.get(Account, id_2).no, 3) self.assertEqual(db.session.get(Account, id_3).no, 2) self.assertEqual(db.session.get(Account, id_4).no, 1) self.assertEqual(db.session.get(Account, id_5).no, 2) def test_reorder(self) -> None: """Tests to reorder the accounts under a same base account. :return: None. """ from accounting.models import Account response: httpx.Response for i in range(2, 6): response = self.__client.post(f"{PREFIX}/store", data={"csrf_token": self.__csrf_token, "base_code": "1111", "title": "Title"}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], f"{PREFIX}/1111-00{i}") # Normal reorder with self.__app.app_context(): id_1: int = Account.find_by_code("1111-001").id id_2: int = Account.find_by_code("1111-002").id id_3: int = Account.find_by_code("1111-003").id id_4: int = Account.find_by_code("1111-004").id id_5: int = Account.find_by_code("1111-005").id response = self.__client.post(f"{PREFIX}/bases/1111", data={"csrf_token": self.__csrf_token, "next": self.__encoded_next_uri, f"{id_1}-no": "4", f"{id_2}-no": "1", f"{id_3}-no": "5", f"{id_4}-no": "2", f"{id_5}-no": "3"}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], NEXT_URI) with self.__app.app_context(): self.assertEqual(db.session.get(Account, id_1).code, "1111-004") self.assertEqual(db.session.get(Account, id_2).code, "1111-001") self.assertEqual(db.session.get(Account, id_3).code, "1111-005") self.assertEqual(db.session.get(Account, id_4).code, "1111-002") self.assertEqual(db.session.get(Account, id_5).code, "1111-003") # Malformed orders with self.__app.app_context(): db.session.get(Account, id_1).no = 3 db.session.get(Account, id_2).no = 4 db.session.get(Account, id_3).no = 6 db.session.get(Account, id_4).no = 8 db.session.get(Account, id_5).no = 9 db.session.commit() response = self.__client.post(f"{PREFIX}/bases/1111", data={"csrf_token": self.__csrf_token, "next": self.__encoded_next_uri, f"{id_2}-no": "3a", f"{id_3}-no": "5", f"{id_4}-no": "2"}) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], NEXT_URI) with self.__app.app_context(): self.assertEqual(db.session.get(Account, id_1).code, "1111-003") self.assertEqual(db.session.get(Account, id_2).code, "1111-004") self.assertEqual(db.session.get(Account, id_3).code, "1111-002") self.assertEqual(db.session.get(Account, id_4).code, "1111-001") self.assertEqual(db.session.get(Account, id_5).code, "1111-005")