diff --git a/.github/workflows/github-actions-demo.yml b/.github/workflows/github-actions-demo.yml new file mode 100644 index 0000000..d7d3579 --- /dev/null +++ b/.github/workflows/github-actions-demo.yml @@ -0,0 +1,59 @@ +name: NETEASE-CICD + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +jobs: + test: + name: ✅ Run Unit Tests + runs-on: ubuntu-latest + steps: + - name: 🔍 Checkout code + uses: actions/checkout@v3 + + - name: 🐍 Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.12" + + - name: Install uv + run: | + curl -Ls https://astral.sh/uv/install.sh | sh + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + + - name: 📦 Install dependencies with uv + run: | + uv sync + - name: 🧪 Run Unit Tests + run: | + source .venv/bin/activate + pytest tests/core/netease/test_controller.py + pytest tests/core/netease/test_services.py + + build-push: + name: 🚀 Build and Push Docker Images + runs-on: ubuntu-latest + needs: test + steps: + - name: 🐳 Checkout Code + uses: actions/checkout@v3 + + - name: 🏗️ Build Image + run: | + docker build -f docker/net.dockerfile -t ${{ secrets.DOCKER_USERNAME }}/netease:latest . + docker build -f docker/streamlit.dockerfile -t ${{ secrets.DOCKER_USERNAME }}/streamlit:latest . + - name: 🔐 Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: 🚀 Push Image + run: | + docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} + docker tag ${{ secrets.DOCKER_USERNAME }}/netease:latest alaricle/netease:latest + docker push alaricle/netease:latest + docker tag ${{ secrets.DOCKER_USERNAME }}/streamlit:latest alaricle/streamlit:latest + docker push alaricle/streamlit:latest diff --git a/.gitignore b/.gitignore index 4fa82ef..1385ce8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ __pycache__ packages/dev_ui/src/dev_ui/common/config/__pycache__/ .venv/ -venv/ +.cache/ \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 2cabab2..4dea062 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,8 @@ dependencies = [ "pydantic>=2.10.6", "pydantic-extra-types[all]>=2.10.2", "pydantic-settings>=2.8.0", + "pytest>=8.3.5", + "pytest-asyncio>=0.26.0", ] [project.scripts] @@ -88,7 +90,7 @@ addopts = [ ] cache_dir = ".cache/pytest" doctest_optionflags = "NUMBER IGNORE_EXCEPTION_DETAIL" -markers = ["slow: mark tests as slow"] +markers = ["slow: mark tests as slow", "asyncio: mark tests as async"] testpaths = ["tests"] verbosity_assertions = 2 xfail_strict = true diff --git a/src/cat/core/net_ease/controller.py b/src/cat/core/net_ease/controller.py index 2111fc3..816c07a 100644 --- a/src/cat/core/net_ease/controller.py +++ b/src/cat/core/net_ease/controller.py @@ -7,12 +7,11 @@ from cat.core.net_ease.dto import SalaryOutput from cat.core.net_ease.services import ( - calculate_tax, calculate_insurance, calculate_personal_deduction, + calculate_tax, ) - if TYPE_CHECKING: from fastapi import UploadFile @@ -20,7 +19,10 @@ def handle_convert_gross_to_net( - gross_salary: float, number_of_dependents: int, region: int, tax_config_dep: TaxConfig + gross_salary: float, + number_of_dependents: int, + region: int, + tax_config_dep: TaxConfig, ) -> SalaryOutput: """Convert gross salary to net salary. @@ -64,14 +66,16 @@ async def handle_upload_excel(file: UploadFile, tax_config_dep: TaxConfig) -> Wo # Prepare a new workbook to write the result to new_wb = Workbook() new_sheet = new_wb.active - new_sheet.append([ - "ID", - "Employee Name", - "Gross Salary", - "Number of Dependents", - "Region", - "Net Salary", - ]) + new_sheet.append( + [ + "ID", + "Employee Name", + "Gross Salary", + "Number of Dependents", + "Region", + "Net Salary", + ] + ) # Iterate through the rows in the original sheet for row in sheet.iter_rows(min_row=2, values_only=True): @@ -92,13 +96,15 @@ async def handle_upload_excel(file: UploadFile, tax_config_dep: TaxConfig) -> Wo ) # Write the result to the new sheet - new_sheet.append([ - employee_id, - employee_name, - gross_salary, - number_of_dependents, - region, - net_salary_output.net_salary, - ]) + new_sheet.append( + [ + employee_id, + employee_name, + gross_salary, + number_of_dependents, + region, + net_salary_output.net_salary, + ] + ) return new_wb diff --git a/tests/core/netease/test_controller.py b/tests/core/netease/test_controller.py new file mode 100644 index 0000000..955a833 --- /dev/null +++ b/tests/core/netease/test_controller.py @@ -0,0 +1,90 @@ +from io import BytesIO + +import pytest +from fastapi import UploadFile +from openpyxl import Workbook +from pytest_mock import MockerFixture + +from cat.core.net_ease.constants import TaxBracket, TaxConfig +from cat.core.net_ease.controller import ( + handle_convert_gross_to_net, + handle_upload_excel, +) +from cat.core.net_ease.dto import SalaryOutput + +tax_config = TaxConfig( + BRACKETS=[ + TaxBracket(limit=5_000_000, rate=0.05), + TaxBracket(limit=10_000_000, rate=0.10), + TaxBracket(limit=18_000_000, rate=0.15), + TaxBracket(limit=32_000_000, rate=0.20), + TaxBracket(limit=52_000_000, rate=0.25), + TaxBracket(limit=80_000_000, rate=0.30), + TaxBracket(limit=float("inf"), rate=0.35), + ] +) + + +def test_handle_convert_gross_to_net(mocker: MockerFixture): + mock_insurance = mocker.patch("cat.core.net_ease.controller.calculate_insurance") + mock_insurance.return_value = 10_000_000 + mock_deduction = mocker.patch( + "cat.core.net_ease.controller.calculate_personal_deduction" + ) + mock_deduction.return_value = 5_000_000 + mock_tax = mocker.patch("cat.core.net_ease.controller.calculate_tax") + mock_tax.return_value = 2_000_000 + + output = handle_convert_gross_to_net( + gross_salary=20_000_000, + number_of_dependents=2, + region=1, + tax_config_dep=tax_config, + ) + + assert isinstance(output, SalaryOutput) + assert output.gross_salary == 20_000_000 + assert output.net_salary == 8_000_000 + assert output.insurance_amount == 10_000_000 + assert output.personal_income_tax == 2_000_000 + + +@pytest.mark.asyncio +async def test_handle_upload_excel(tmp_path): + # Tạo workbook giả + wb = Workbook() + ws = wb.active + ws.append(["ID", "Employee Name", "Gross Salary", "Number of Dependents", "Region"]) + ws.append([1, "Alice", 20000000, 2, 1]) + ws.append([2, "Bob", 30000000, 1, 2]) + + # Lưu vào bytes + file_stream = BytesIO() + wb.save(file_stream) + file_stream.seek(0) + + # Tạo UploadFile giả + upload_file = UploadFile( + filename="tests/data_test/data_test_gross_net.xlsx", file=file_stream + ) + + # Gọi hàm upload + result_wb = await handle_upload_excel(upload_file, tax_config) + + assert isinstance(result_wb, Workbook) + + result_sheet = result_wb.active + rows = list(result_sheet.iter_rows(values_only=True)) + + # Header + 2 nhân viên + assert len(rows) == 3 + assert rows[0] == ( + "ID", + "Employee Name", + "Gross Salary", + "Number of Dependents", + "Region", + "Net Salary", + ) + assert rows[1][1] == "Alice" + assert isinstance(rows[1][-1], float) # Net Salary diff --git a/tests/core/netease/test_services.py b/tests/core/netease/test_services.py new file mode 100644 index 0000000..d32fb98 --- /dev/null +++ b/tests/core/netease/test_services.py @@ -0,0 +1,65 @@ +import pytest + +from cat.core.net_ease.constants import ConstantsSalary, TaxConfig +from cat.core.net_ease.services import ( + calculate_insurance, + calculate_personal_deduction, + calculate_tax, +) + + +def test_calculate_personal_deduction() -> None: + assert calculate_personal_deduction(0) == ConstantsSalary.BASIC_DEDUCTION + assert ( + calculate_personal_deduction(2) + == ConstantsSalary.BASIC_DEDUCTION + 2 * ConstantsSalary.DEPENDENT_DEDUCTION + ) + + +@pytest.mark.parametrize( + "gross_salary, region, expected", + [ + ( + 20_000_000, + 1, + pytest.approx( + min(20_000_000, ConstantsSalary.LIMIT_BH) * ConstantsSalary.BHXH_RATE + + min(20_000_000, ConstantsSalary.LIMIT_BH) * ConstantsSalary.BHYT_RATE + + min(20_000_000, ConstantsSalary.LIMIT_BHTN_V1) + * ConstantsSalary.BHTN_RATE + ), + ), + ( + 100_000_000, + 2, + pytest.approx( + ConstantsSalary.LIMIT_BH * ConstantsSalary.BHXH_RATE + + ConstantsSalary.LIMIT_BH * ConstantsSalary.BHYT_RATE + + ConstantsSalary.LIMIT_BHTN_V2 * ConstantsSalary.BHTN_RATE + ), + ), + ], +) +def test_calculate_insurance(gross_salary, region, expected): + assert calculate_insurance(gross_salary, region) == expected + + +def test_calculate_tax(): + tax_config = TaxConfig() + + # Case: 4,000,000 income → 5% bracket only + assert calculate_tax(4_000_000, tax_config) == 4_000_000 * 0.05 + + # Case: 10,000,000 income → spans 5% + 10% + expected_tax = 5_000_000 * 0.05 + (10_000_000 - 5_000_000) * 0.10 + assert calculate_tax(10_000_000, tax_config) == expected_tax + + # Case: 50,000,000 income → spans multiple brackets + expected_tax = ( + 5_000_000 * 0.05 + + 5_000_000 * 0.10 + + 8_000_000 * 0.15 + + 14_000_000 * 0.20 + + (50_000_000 - 32_000_000) * 0.25 + ) + assert calculate_tax(50_000_000, tax_config) == expected_tax diff --git a/uv.lock b/uv.lock index 1fa3ea0..efe8c0d 100644 --- a/uv.lock +++ b/uv.lock @@ -153,6 +153,8 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-extra-types", extra = ["all"] }, { name = "pydantic-settings" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, ] [package.dev-dependencies] @@ -182,6 +184,8 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.10.6" }, { name = "pydantic-extra-types", extras = ["all"], specifier = ">=2.10.2" }, { name = "pydantic-settings", specifier = ">=2.8.0" }, + { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-asyncio", specifier = ">=0.26.0" }, ] [package.metadata.requires-dev] @@ -749,7 +753,7 @@ name = "importlib-metadata" version = "8.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.11'" }, + { name = "zipp", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/08/c1395a292bb23fd03bdf572a1357c5a733d3eecbab877641ceacab23db6e/importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580", size = 55767 } wheels = [ @@ -1987,6 +1991,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, ] +[[package]] +name = "pytest-asyncio" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/c4/453c52c659521066969523e87d85d54139bbd17b78f09532fb8eb8cdb58e/pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f", size = 54156 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694 }, +] + [[package]] name = "pytest-benchmark" version = "5.1.0"