diff --git a/.env.example b/.env.example index ad267516d..e51bf8067 100644 --- a/.env.example +++ b/.env.example @@ -7,31 +7,20 @@ CI_ENVIRONMENT = production #-------------------------------------------------------------------- # SECURITY: ALLOWED HOSTNAMES #-------------------------------------------------------------------- -# IMPORTANT: Whitelist of allowed hostnames to prevent Host Header +# CRITICAL: Whitelist of allowed hostnames to prevent Host Header # Injection attacks (GHSA-jchf-7hr6-h4f3). # -# If not configured, the application will default to 'localhost', -# which may break functionality in production. +# REQUIRED IN PRODUCTION: Application will fail to start if not configured. +# In development, falls back to 'localhost' with an error log. # -# Configure this with all domains/subdomains that host your application: -# - Primary domain -# - WWW subdomain (if used) -# - Any alternative domains +# Configure with comma-separated list of domains/subdomains: +# app.allowedHostnames = 'yourdomain.com,www.yourdomain.com' # -# Examples: -# Single domain: -# app.allowedHostnames.0 = 'example.com' +# For local development: +# app.allowedHostnames = 'localhost' # -# Multiple domains: -# app.allowedHostnames.0 = 'example.com' -# app.allowedHostnames.1 = 'www.example.com' -# app.allowedHostnames.2 = 'demo.opensourcepos.org' -# -# For localhost development: -# app.allowedHostnames.0 = 'localhost' -# -# Note: Do not include the protocol (http/https) or port number. -#app.allowedHostnames.0 = '' +# Note: Do not include protocol (http/https) or port numbers. +app.allowedHostnames = '' #-------------------------------------------------------------------- # DATABASE diff --git a/.github/ISSUE_TEMPLATE/bug report.yml b/.github/ISSUE_TEMPLATE/bug report.yml index c026d3144..fddd1ddcc 100644 --- a/.github/ISSUE_TEMPLATE/bug report.yml +++ b/.github/ISSUE_TEMPLATE/bug report.yml @@ -1,121 +1,187 @@ -name: Bug Report -description: File a bug report -title: "[Bug]: " -labels: ["bug", "triage"] -projects: ["ospos/3", "ospos/4"] -assignees: - - none -body: - - type: markdown - attributes: - value: | - Bug reports indicate that something is not working as intended. - Please include as much detail as possible and submit a separate bug report for each problem. - Do not include personal identifying information such as email addresses or encryption keys. - - type: textarea - id: bug-description - attributes: - label: Bug Description? - description: Describe the problem that you are seeing - placeholder: "Describe the problem that you are seeing" - validations: - required: true - - type: textarea - id: steps-reproduce - attributes: - label: Steps to Reproduce? - description: List the steps to reproduce this issue - placeholder: "Steps to Reproduce" - validations: - required: true - - type: textarea - id: expected-behavior - attributes: - label: Expected Behavior? - description: Tell us what did you expect to happen? - placeholder: "Expected Behavior" - validations: - required: true - - type: dropdown - id: ospos-version - attributes: - label: OpensourcePOS Version - description: What version of our software are you running? - options: - - development (unreleased) - - opensourcepos 3.4.1 - - opensourcepos 3.4.0 - - opensourcepos 3.3.9 - - opensourcepos 3.3.8 - - opensourcepos 3.3.7 - default: 0 - validations: - required: true - - type: dropdown - id: php-version - attributes: - label: Php version - description: What version of Php? - options: - - Php 7.2 - - Php 7.3 - - Php 7.4 - - Php 8.1 - - Php 8.2 - - Php 8.3 - - Php 8.4 - default: 0 - validations: - required: true - - type: dropdown - id: browsers - attributes: - label: What browsers are you seeing the problem on? - multiple: true - options: - - Firefox - - Chrome - - Safari - - Microsoft Edge - - Other - - type: input - id: server - attributes: - label: Server Operating System and version - description: "Server Operating System " - placeholder: "Server Operating System " - validations: - required: true - - type: input - id: database - attributes: - label: Database Management System and version - description: "Database Management System" - placeholder: "Database Management" - validations: - required: true - - type: input - id: webserver - attributes: - label: Web Server and version - description: "Web Server and version " - placeholder: "Web Server and version " - validations: - required: true - - type: textarea - id: servers - attributes: - label: System Information Report (optional) - description: Copy and paste from OSPOS > Configuration > Setup & Conf > Setup & Conf? - placeholder: System Information Report - value: "System Information Report" - validations: - required: true - - type: checkboxes - id: terms - attributes: - label: Unmodified copy of OpensourcePOS - description: By submitting this issue you agree this copy has not been modified - options: - - label: I agree this copy has not been modified - required: true +name: 🐛 Bug Report +description: File a bug report to help us improve +title: "[Bug]: " +labels: ["bug", "triage"] +projects: ["ospos/3", "ospos/4"] +assignees: [] +body: + # ───────────────────────────────────────────────────────────────────────────── + # INTRODUCTION + # ───────────────────────────────────────────────────────────────────────────── + - type: markdown + attributes: + value: | + ## Thanks for taking the time to fill out this bug report! 🐜 + + Bug reports help us identify and fix issues. Please provide as much detail as possible. + + > ⚠️ **Important:** Submit a separate bug report for each problem you encounter. + > + > 🚫 Do not include personal identifying information such as email addresses or encryption keys. + + # ───────────────────────────────────────────────────────────────────────────── + # PROBLEM DESCRIPTION + # ───────────────────────────────────────────────────────────────────────────── + - type: textarea + id: bug-description + attributes: + label: 🐛 Bug Description + description: A clear and concise description of what the bug is. + placeholder: | + Example: When I try to print a receipt, the application crashes + with an error message saying "Unable to connect to printer". + validations: + required: true + + - type: textarea + id: steps-reproduce + attributes: + label: 📋 Steps to Reproduce + description: Detailed steps to reproduce the behavior. + placeholder: | + 1. Go to '...' + 2. Click on '...' + 3. Scroll down to '...' + 4. See error + validations: + required: true + + - type: textarea + id: expected-behavior + attributes: + label: ✅ Expected Behavior + description: A clear and concise description of what you expected to happen. + placeholder: | + Example: The receipt should print successfully without any errors. + validations: + required: true + + # ───────────────────────────────────────────────────────────────────────────── + # ENVIRONMENT DETAILS + # ───────────────────────────────────────────────────────────────────────────── + - type: dropdown + id: ospos-version + attributes: + label: 📦 OpenSourcePOS Version + description: What version of our software are you running? + options: + - development (unreleased) + - OpenSourcePOS 3.4.2 + - OpenSourcePOS 3.4.1 + - OpenSourcePOS 3.4.0 + - OpenSourcePOS 3.3.9 + - OpenSourcePOS 3.3.8 + default: 0 + validations: + required: true + + - type: dropdown + id: php-version + attributes: + label: 🔧 PHP Version + description: What version of PHP are you running? + options: + - PHP 8.4 + - PHP 8.3 + - PHP 8.2 + - PHP 8.1 + - PHP 7.4 + - Other + default: 0 + validations: + required: true + + - type: dropdown + id: browsers + attributes: + label: 🌐 Browser(s) + description: What browser(s) are you seeing the problem on? + multiple: true + options: + - Firefox + - Chrome + - Safari + - Microsoft Edge + - Other + + - type: input + id: server + attributes: + label: 🖥️ Server Operating System + description: What server OS and version are you running? + placeholder: "e.g., Ubuntu 22.04, CentOS 7, Windows Server 2022" + validations: + required: true + + - type: input + id: database + attributes: + label: 🗄️ Database + description: What database management system and version are you using? + placeholder: "e.g., MySQL 8.0, MariaDB 10.11, Percona 8.0" + validations: + required: true + + - type: input + id: webserver + attributes: + label: 🌍 Web Server + description: What web server and version are you using? + placeholder: "e.g., Apache 2.4, Nginx 1.24, Caddy 2.7" + validations: + required: true + + # ───────────────────────────────────────────────────────────────────────────── + # ADDITIONAL INFORMATION + # ───────────────────────────────────────────────────────────────────────────── + - type: textarea + id: system-info + attributes: + label: 📊 System Information Report + description: | + Copy and paste the system information from OSPOS: + + **Navigation:** Configuration → Setup & Conf → System Info + placeholder: | + Paste the System Information Report here... + render: text + validations: + required: true + + - type: textarea + id: logs + attributes: + label: 📜 Relevant Log Output + description: | + Please copy and paste any relevant log output. + + **Log locations:** + - OSPOS logs: `writable/logs/` + - Web server logs: `/var/log/apache2/` or `/var/log/nginx/` + - PHP logs: Check your `php.ini` for `error_log` location + placeholder: | + Paste log output here... + render: shell + + - type: textarea + id: screenshots + attributes: + label: 📸 Screenshots + description: If applicable, add screenshots to help explain your problem. + placeholder: Drag and drop images here... + + # ───────────────────────────────────────────────────────────────────────────── + # CONFIRMATION + # ───────────────────────────────────────────────────────────────────────────── + - type: checkboxes + id: terms + attributes: + label: ✓ Confirmation + description: Please confirm the following before submitting + options: + - label: I certify that this is an unmodified copy of OpenSourcePOS + required: true + - label: I have searched existing issues to ensure this bug has not already been reported + required: true + - label: I have provided all the information requested above + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 028fa0b3d..16714eb90 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,63 +1,136 @@ -name: ✨ Feature Request -description: Suggest an idea for this project -title: "[Feature]: " -labels: ["enhancement"] -assignees: ["none"] -body: - - type: markdown - attributes: - value: | - Thanks for taking the time to fill out this feature request! 🤗 - Please make sure this feature request hasn't been already submitted by someone by looking through other open/closed issues. 😃 - - - type: dropdown - attributes: - multiple: false - label: Type of Feature - description: Select the type of feature request. - options: - - "✨ New Feature" - - "📝 Documentation" - - "🎨 Style and UI" - - "🔨 Code Refactor" - - "⚡ Performance Improvements" - - "✅ New Test" - validations: - required: true - - - type: dropdown - id: ospos-version - attributes: - label: OpensourcePOS Version - description: What version of our software are you running? - options: - - opensourcepos 3.3.9 - - opensourcepos 3.3.8 - - opensourcepos 3.3.7 - default: 0 - validations: - required: true - - - type: textarea - id: description - attributes: - label: Description - description: Give us a brief description of the feature or enhancement you would like - validations: - required: true - - - type: textarea - id: additional-information - attributes: - label: Additional Information - description: Give us some additional information on the feature request like proposed solutions, links, screenshots, etc. - - - type: checkboxes - id: terms - attributes: - label: Verify you searched open requests in OpensourcePOS - description: By submitting this request you agree that you have searched Open Requests in the Tracker - options: - - label: I agree I have searched Open Requests - required: true - +name: ✨ Feature Request +description: Suggest an idea or enhancement for this project +title: "[Feature]: " +labels: ["enhancement"] +assignees: [] +body: + # ───────────────────────────────────────────────────────────────────────────── + # INTRODUCTION + # ───────────────────────────────────────────────────────────────────────────── + - type: markdown + attributes: + value: | + ## Thanks for suggesting a new feature! 💡 + + We appreciate you taking the time to help improve OpenSourcePOS. + + > 📋 **Before submitting:** Please search [existing feature requests](https://github.com/opensourcepos/opensourcepos/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement) to ensure your idea hasn't already been suggested. + + # ───────────────────────────────────────────────────────────────────────────── + # FEATURE DETAILS + # ───────────────────────────────────────────────────────────────────────────── + - type: dropdown + id: feature-type + attributes: + label: 🏷️ Feature Type + description: What type of feature are you requesting? + options: + - "✨ New Feature" + - "📝 Documentation Improvement" + - "🎨 UI/UX Enhancement" + - "🔨 Code Refactoring" + - "⚡ Performance Improvement" + - "✅ New Test Coverage" + - "🔌 Plugin/Integration" + default: 0 + validations: + required: true + + - type: dropdown + id: ospos-version + attributes: + label: 📦 OpenSourcePOS Version + description: What version are you currently running? + options: + - development (unreleased) + - OpenSourcePOS 3.4.2 + - OpenSourcePOS 3.4.1 + - OpenSourcePOS 3.4.0 + - OpenSourcePOS 3.3.9 + - OpenSourcePOS 3.3.8 + default: 0 + validations: + required: true + + - type: textarea + id: problem-statement + attributes: + label: 🎯 Problem Statement + description: | + Is your feature request related to a problem? Please describe. + + A clear description of what the problem is. Ex: I'm always frustrated when [...] + placeholder: | + Example: I always have to manually calculate taxes for different regions, + which is time-consuming and error-prone. + validations: + required: true + + - type: textarea + id: proposed-solution + attributes: + label: 💡 Proposed Solution + description: A clear and concise description of what you want to happen. + placeholder: | + Example: Add an automatic tax calculation feature that: + - Detects the customer's region + - Applies the correct tax rate + - Generates a tax report automatically + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: 🔄 Alternatives Considered + description: A clear description of any alternative solutions or features you've considered. + placeholder: | + Example: I considered using an external tax service, but it would be + better to have this integrated directly into OpenSourcePOS. + + # ───────────────────────────────────────────────────────────────────────────── + # ADDITIONAL INFORMATION + # ───────────────────────────────────────────────────────────────────────────── + - type: textarea + id: additional-context + attributes: + label: 📎 Additional Context + description: | + Add any other context, screenshots, mockups, or references about the feature request here. + + **Helpful additions:** + - Links to similar features in other software + - Mockups or diagrams + - Code examples + - Documentation references + placeholder: | + Any other relevant information, links, or screenshots... + + - type: textarea + id: acceptance-criteria + attributes: + label: ✅ Acceptance Criteria + description: | + (Optional) Define what "done" looks like for this feature. + + Format: **Given** [context], **When** [action], **Then** [outcome] + placeholder: | + Given a customer is selected from region X + When the sale is completed + Then the tax rate for region X is automatically applied + And the tax amount is correctly calculated + And a tax entry is logged in the report + + # ───────────────────────────────────────────────────────────────────────────── + # CONFIRMATION + # ───────────────────────────────────────────────────────────────────────────── + - type: checkboxes + id: terms + attributes: + label: ✓ Confirmation + description: Please confirm before submitting + options: + - label: I have searched existing feature requests to ensure this is not a duplicate + required: true + - label: I have provided a clear problem statement and proposed solution + required: true \ No newline at end of file diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 9320f3a4a..82438ec67 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -155,7 +155,7 @@ jobs: run: | BRANCH=$(echo "${GITHUB_REF#refs/heads/}" | tr '/' '_') if [ "$BRANCH" = "master" ]; then - echo "tags=${{ secrets.DOCKER_USERNAME }}/opensourcepos:${{ needs.build.outputs.version-tag }},${{ secrets.DOCKER_USERNAME }}/opensourcepos:latest" >> $GITHUB_OUTPUT + echo "tags=${{ secrets.DOCKER_USERNAME }}/opensourcepos:${{ needs.build.outputs.version-tag }},${{ secrets.DOCKER_USERNAME }}/opensourcepos:master" >> $GITHUB_OUTPUT else echo "tags=${{ secrets.DOCKER_USERNAME }}/opensourcepos:${{ needs.build.outputs.version-tag }}" >> $GITHUB_OUTPUT fi diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f6dd73190..4d823dc3f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -28,7 +28,6 @@ jobs: fail-fast: false matrix: php-version: - - '8.1' - '8.2' - '8.3' - '8.4' diff --git a/.github/workflows/php-linter.yml b/.github/workflows/php-linter.yml index c27f5535c..fb94c5b86 100644 --- a/.github/workflows/php-linter.yml +++ b/.github/workflows/php-linter.yml @@ -12,14 +12,6 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - - name: PHP Lint 8.0 - uses: dbfx/github-phplint/8.0@master - with: - folder-to-exclude: "! -path \"./vendor/*\" ! -path \"./folder/excluded/*\"" - - name: PHP Lint 8.1 - uses: dbfx/github-phplint/8.1@master - with: - folder-to-exclude: "! -path \"./vendor/*\" ! -path \"./folder/excluded/*\"" - name: PHP Lint 8.2 uses: dbfx/github-phplint/8.2@master with: diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 2198acaf8..3fe599ca6 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -34,7 +34,6 @@ jobs: fail-fast: false matrix: php-version: - - '8.1' - '8.2' - '8.3' - '8.4' @@ -119,4 +118,4 @@ jobs: - name: Stop MariaDB if: always() - run: docker stop mysql && docker rm mysql \ No newline at end of file + run: docker stop mysql && docker rm mysql diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..de5582582 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,172 @@ +name: Release Version Bump + +on: + workflow_dispatch: + inputs: + version_type: + description: 'Version bump type' + required: true + type: choice + options: + - minor + - major + - patch + default: 'minor' + +permissions: + contents: write + +jobs: + prepare-release: + name: Prepare Release + runs-on: ubuntu-22.04 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Get current version + id: current_version + run: | + CURRENT_VERSION=$(grep "application_version" app/Config/App.php | sed "s/.*= '\(.*\)';/\1/g") + echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + echo "Current version: $CURRENT_VERSION" + + - name: Calculate new version + id: version + run: | + CURRENT_VERSION="${{ steps.current_version.outputs.current_version }}" + VERSION_TYPE="${{ github.event.inputs.version_type }}" + + # Parse current version + MAJOR=$(echo $CURRENT_VERSION | cut -d. -f1) + MINOR=$(echo $CURRENT_VERSION | cut -d. -f2) + PATCH=$(echo $CURRENT_VERSION | cut -d. -f3) + + # Bump version based on type + case $VERSION_TYPE in + major) + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + ;; + minor) + MINOR=$((MINOR + 1)) + PATCH=0 + ;; + patch) + PATCH=$((PATCH + 1)) + ;; + esac + + NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "previous_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + echo "New version: $NEW_VERSION (was: $CURRENT_VERSION, type: $VERSION_TYPE)" + + - name: Update version in App.php + run: | + NEW_VERSION="${{ steps.version.outputs.new_version }}" + sed -i "s/public string \\\$application_version = '[^']*';/public string \\\$application_version = '$NEW_VERSION';/" app/Config/App.php + echo "Updated app/Config/App.php" + + - name: Update version in package.json + run: | + NEW_VERSION="${{ steps.version.outputs.new_version }}" + sed -i "s/\"version\": \"[^\"]*\",/\"version\": \"$NEW_VERSION\",/" package.json + echo "Updated package.json" + + - name: Update version in docker-compose.nginx.yml + run: | + NEW_VERSION="${{ steps.version.outputs.new_version }}" + sed -i "s/jekkos\/opensourcepos:[^ ]*/jekkos\/opensourcepos:$NEW_VERSION/" docker-compose.nginx.yml + echo "Updated docker-compose.nginx.yml" + + - name: Update version in README.md + run: | + NEW_VERSION="${{ steps.version.outputs.new_version }}" + # Extract major.minor for the "latest X.Y version" text + MAJOR_MINOR=$(echo "$NEW_VERSION" | cut -d. -f1,2) + sed -i "s/The latest \`[0-9]*\.[0-9]*\` version/The latest \`${MAJOR_MINOR}\` version/" README.md + echo "Updated README.md with version ${MAJOR_MINOR}" + + - name: Generate changelog + id: changelog + run: | + PREVIOUS_VERSION="${{ steps.version.outputs.previous_version }}" + NEW_VERSION="${{ steps.version.outputs.new_version }}" + + # Get commits since last version + if git rev-parse "$PREVIOUS_VERSION" >/dev/null 2>&1; then + COMMITS=$(git log "$PREVIOUS_VERSION"..HEAD --pretty=format:"- %s" --no-merges) + else + COMMITS=$(git log --pretty=format:"- %s" --no-merges -50) + fi + + # Create changelog entry + CHANGELOG_FILE="CHANGELOG.md" + + # Create the new version comparison link + NEW_LINK="[${NEW_VERSION}]: https://github.com/opensourcepos/opensourcepos/compare/${PREVIOUS_VERSION}...${NEW_VERSION}" + + # Insert new link after [unreleased] line + sed -i "/^\[unreleased\]/a $NEW_LINK" "$CHANGELOG_FILE" + + # Update [unreleased] link to start from new version + sed -i "s|^\[unreleased\]: .*|\[unreleased\]: https://github.com/opensourcepos/opensourcepos/compare/${NEW_VERSION}...HEAD|" "$CHANGELOG_FILE" + + # Create version header and content using temp file to avoid sed issues with special characters + VERSION_DATE=$(date +%Y-%m-%d) + VERSION_HEADER="## [$NEW_VERSION] - $VERSION_DATE" + + # Create temp file with changelog entry + TMP_FILE=$(mktemp) + { + echo "" + echo "$VERSION_HEADER" + echo "" + echo "$COMMITS" + } > "$TMP_FILE" + + # Insert after Unreleased header + sed -i "/^## \[Unreleased\]/r $TMP_FILE" "$CHANGELOG_FILE" + rm "$TMP_FILE" + + echo "Updated CHANGELOG.md" + echo "Changelog entries:" + echo "$COMMITS" + + - name: Update version in issue templates + run: | + NEW_VERSION="${{ steps.version.outputs.new_version }}" + + # Calculate version to remove (keep 5 versions) + PREVIOUS_VERSION="${{ steps.version.outputs.previous_version }}" + + # Bug report template - insert new version after development (unreleased) + BUG_TEMPLATE=".github/ISSUE_TEMPLATE/bug report.yml" + sed -i "/- development (unreleased)/a\\ - OpenSourcePOS ${NEW_VERSION}" "$BUG_TEMPLATE" + # Remove the oldest version (5th version from the end) + sed -i "/OpenSourcePOS 3\\.3\\.7/d" "$BUG_TEMPLATE" + echo "Updated $BUG_TEMPLATE" + + # Feature request template - insert new version after development (unreleased) + FEATURE_TEMPLATE=".github/ISSUE_TEMPLATE/feature_request.yml" + sed -i "/- development (unreleased)/a\\ - OpenSourcePOS ${NEW_VERSION}" "$FEATURE_TEMPLATE" + # Remove the oldest version (5th version from the end) + sed -i "/OpenSourcePOS 3\\.3\\.7/d" "$FEATURE_TEMPLATE" + echo "Updated $FEATURE_TEMPLATE" + + - name: Commit version bump + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + NEW_VERSION="${{ steps.version.outputs.new_version }}" + + git add app/Config/App.php package.json docker-compose.nginx.yml CHANGELOG.md README.md .github/ISSUE_TEMPLATE/ + git commit -m "chore: release version $NEW_VERSION" + git push origin HEAD \ No newline at end of file diff --git a/.github/workflows/update-issue-templates.yml b/.github/workflows/update-issue-templates.yml deleted file mode 100644 index f101a6264..000000000 --- a/.github/workflows/update-issue-templates.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Update Issue Templates - -on: - release: - types: [published] - workflow_dispatch: - schedule: - - cron: '0 0 * * 0' - -jobs: - update-templates: - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Fetch releases and update templates - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Fetch releases from GitHub API - RELEASES=$(gh api repos/${{ github.repository }}/releases --jq '.[].tag_name' | head -n 10) - - # Create temporary file with options - OPTIONS_FILE=$(mktemp) - echo " - development (unreleased)" >> "$OPTIONS_FILE" - while IFS= read -r release; do - echo " - opensourcepos $release" >> "$OPTIONS_FILE" - done <<< "$RELEASES" - - update_template() { - local template="$1" - local template_path=".github/ISSUE_TEMPLATE/$template" - - # Find the line numbers for the OpensourcePOS Version dropdown - start_line=$(grep -n "label: OpensourcePOS Version" "$template_path" | cut -d: -f1) - - if [ -z "$start_line" ]; then - echo "Could not find OpensourcePOS Version in $template" - return 1 - fi - - # Find the options section and default line - options_start=$((start_line + 3)) - default_line=$(grep -n "default:" "$template_path" | awk -F: -v opts="$options_start" '$1 > opts {print $1; exit}') - - # Create new template file - head -n $((options_start - 1)) "$template_path" > "${template_path}.new" - cat "$OPTIONS_FILE" >> "${template_path}.new" - tail -n +$default_line "$template_path" >> "${template_path}.new" - mv "${template_path}.new" "$template_path" - - echo "Updated $template" - } - - update_template "bug report.yml" - update_template "feature_request.yml" - - - name: Commit and push changes - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add .github/ISSUE_TEMPLATE/*.yml - if git diff --staged --quiet; then - echo "No changes to commit" - else - git commit -m "Update issue templates with latest releases [skip ci]" - git push - fi \ No newline at end of file diff --git a/Dockerfile.test b/Dockerfile.test deleted file mode 100644 index 3729f6ac9..000000000 --- a/Dockerfile.test +++ /dev/null @@ -1,3 +0,0 @@ -FROM php:8.4-cli -RUN apt-get update && apt-get install -y libicu-dev && docker-php-ext-install intl -WORKDIR /app \ No newline at end of file diff --git a/INSTALL.md b/INSTALL.md index b63ff893f..b688cf972 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,6 +1,6 @@ ## Server Requirements -- PHP version `8.1` to `8.4` are supported, PHP version `≤7.4` is NOT supported. Please note that PHP needs to have the extensions `php-json`, `php-gd`, `php-bcmath`, `php-intl`, `php-openssl`, `php-mbstring`, `php-curl` and `php-xml` installed and enabled. An unstable master build can be downloaded in the releases section. +- PHP version `8.2` to `8.4` are supported, PHP version `≤ 8.1` is NOT supported. Please note that PHP needs to have the extensions `php-json`, `php-gd`, `php-bcmath`, `php-intl`, `php-openssl`, `php-mbstring`, `php-curl` and `php-xml` installed and enabled. An unstable master build can be downloaded in the releases section. - MySQL `5.7` is supported, also MariaDB replacement `10.x` is supported and might offer better performance. - Apache `2.4` is supported. Nginx should work fine too, see [wiki page here](https://github.com/opensourcepos/opensourcepos/wiki/Local-Deployment-using-LEMP). - Raspberry PI based installations proved to work, see [wiki page here](). @@ -8,26 +8,36 @@ ## Security Configuration -### Allowed Hostnames (Required for Production) +### Allowed Hostnames (REQUIRED for Production) -OpenSourcePOS validates the Host header against a whitelist to prevent Host Header Injection attacks (GHSA-jchf-7hr6-h4f3). **You must configure this for production deployments.** +⚠️ **CRITICAL**: OpenSourcePOS validates the Host header to prevent Host Header Injection attacks (GHSA-jchf-7hr6-h4f3). **You MUST configure `app.allowedHostnames` for production deployments. If not configured, the application will fail to start.** -Add the following to your `.env` file: +**Add to your `.env` file:** -``` -app.allowedHostnames.0 = 'yourdomain.com' -app.allowedHostnames.1 = 'www.yourdomain.com' +```bash +# Comma-separated list of allowed hostnames (no protocols or ports) +app.allowedHostnames = 'yourdomain.com,www.yourdomain.com' ``` -**For local development**, use: -``` -app.allowedHostnames.0 = 'localhost' +**For local development:** + +```bash +app.allowedHostnames = 'localhost' ``` -If `allowedHostnames` is not configured: -1. A security warning will be logged -2. The application will fall back to 'localhost' as the hostname -3. This means URLs generated by the application (links, redirects, etc.) will point to 'localhost' +**If you see this error at startup:** + +```text +RuntimeException: Security: allowedHostnames is not configured. +``` + +**Solution**: Add `app.allowedHostnames` to your `.env` file with your domain(s). + +**Why this matters:** +- Prevents Host Header Injection attacks (GHSA-jchf-7hr6-h4f3) +- Ensures URLs are generated with the correct domain +- Security advisory: https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-jchf-7hr6-h4f3 +- Fixes issue #4480: .env configuration now works via comma-separated values ### HTTPS Behind Proxy diff --git a/README.md b/README.md index 1a46a4386..56c47dd17 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ NOTE: If you're running non-release code, please make sure you always run the la - If you have suhosin installed and face an issue with CSRF, please make sure you read [issue #1492](https://github.com/opensourcepos/opensourcepos/issues/1492). -- PHP `≥ 8.1` is required to run this app. +- PHP `≥ 8.2` is required to run this app. ## 🏃 Keep the Machine Running diff --git a/app/Config/App.php b/app/Config/App.php index a24c70dd0..db4b0a876 100644 --- a/app/Config/App.php +++ b/app/Config/App.php @@ -55,21 +55,13 @@ class App extends BaseConfig public string $baseURL; // Defined in the constructor /** - * Allowed Hostnames for the Site URL. - * - * Security: This is used to validate the HTTP Host header to prevent - * Host Header Injection attacks. If the Host header doesn't match - * an entry in this list, the request will use the first allowed hostname. - * - * IMPORTANT: This MUST be configured for production deployments. - * If empty, the application will fall back to 'localhost'. - * - * Configure via .env file: - * app.allowedHostnames.0 = 'example.com' - * app.allowedHostnames.1 = 'www.example.com' - * - * For local development: - * app.allowedHostnames.0 = 'localhost' + * Allowed Hostnames in the Site URL other than the hostname in the baseURL. + * If you want to accept multiple Hostnames, set this. + * + * E.g., + * When your site URL ($baseURL) is 'http://example.com/', and your site + * also accepts 'http://media.example.com/' and 'http://accounts.example.com/': + * ['media.example.com', 'accounts.example.com'] * * @var list */ @@ -125,7 +117,7 @@ class App extends BaseConfig | DO NOT CHANGE THIS UNLESS YOU FULLY UNDERSTAND THE REPERCUSSIONS!! | */ - public string $permittedURIChars = 'a-z 0-9~%.:_\-='; + public string $permittedURIChars = 'a-z 0-9~%.:_\-'; /** * -------------------------------------------------------------------------- @@ -286,13 +278,24 @@ class App extends BaseConfig * @see http://www.html5rocks.com/en/tutorials/security/content-security-policy/ * @see http://www.w3.org/TR/CSP/ */ - public bool $CSPEnabled = false; // TODO: Currently CSP3 tags are not supported so enabling this causes problems with script-src-elem, style-src-attr and style-src-elem + public bool $CSPEnabled = false; public function __construct() { parent::__construct(); + + // Solution for CodeIgniter 4 limitation: arrays cannot be set from .env + // See: https://github.com/codeigniter4/CodeIgniter4/issues/7311 + $envAllowedHostnames = getenv('app.allowedHostnames'); + if ($envAllowedHostnames !== false && trim($envAllowedHostnames) !== '') { + $this->allowedHostnames = array_values(array_filter( + array_map('trim', explode(',', $envAllowedHostnames)), + static fn (string $hostname): bool => $hostname !== '' + )); + } + $this->https_on = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on') || (isset($_ENV['FORCE_HTTPS']) && $_ENV['FORCE_HTTPS'] == 'true'); - + $host = $this->getValidHost(); $this->baseURL = $this->https_on ? 'https' : 'http'; $this->baseURL .= '://' . $host . '/'; @@ -301,23 +304,40 @@ class App extends BaseConfig /** * Validates and returns a trusted hostname. - * + * * Security: Prevents Host Header Injection attacks (GHSA-jchf-7hr6-h4f3) * by validating the HTTP_HOST against a whitelist of allowed hostnames. - * + * + * In production: Fails fast if allowedHostnames is not configured. + * In development: Allows localhost fallback with an error log. + * * @return string A validated hostname + * @throws \RuntimeException If allowedHostnames is not configured in production */ private function getValidHost(): string { $httpHost = $_SERVER['HTTP_HOST'] ?? 'localhost'; + // Determine environment + // CodeIgniter's test bootstrap sets $_SERVER['CI_ENVIRONMENT'] = 'testing' + // Check $_SERVER first, then $_ENV, then fall back to 'production' + $environment = $_SERVER['CI_ENVIRONMENT'] ?? $_ENV['CI_ENVIRONMENT'] ?? getenv('CI_ENVIRONMENT') ?: 'production'; + if (empty($this->allowedHostnames)) { - log_message('warning', + $errorMessage = 'Security: allowedHostnames is not configured. ' . 'Host header injection protection is disabled. ' . - 'Please set app.allowedHostnames in your .env file. ' . - 'Received Host: ' . $httpHost - ); + 'Set app.allowedHostnames in your .env file. ' . + 'Example: app.allowedHostnames = "example.com,www.example.com" ' . + 'Received Host: ' . $httpHost; + + // Production: Fail explicitly to prevent silent security vulnerabilities + // Testing and development: Allow localhost fallback + if ($environment === 'production') { + throw new \RuntimeException($errorMessage); + } + + log_message('error', $errorMessage . ' Using localhost fallback (development only).'); return 'localhost'; } @@ -325,7 +345,8 @@ class App extends BaseConfig return $httpHost; } - log_message('warning', + // Host not in whitelist - use first configured hostname as fallback + log_message('warning', 'Security: Rejected HTTP_HOST "' . $httpHost . '" - not in allowedHostnames whitelist. ' . 'Using fallback: ' . $this->allowedHostnames[0] ); diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php index beec06e56..7ef9fd310 100644 --- a/app/Config/Autoload.php +++ b/app/Config/Autoload.php @@ -17,8 +17,6 @@ use CodeIgniter\Config\AutoloadConfig; * * NOTE: This class is required prior to Autoloader instantiation, * and does not extend BaseConfig. - * - * @immutable */ class Autoload extends AutoloadConfig { diff --git a/app/Config/Boot/testing.php b/app/Config/Boot/testing.php index 02fd04a2b..40b6ca83c 100644 --- a/app/Config/Boot/testing.php +++ b/app/Config/Boot/testing.php @@ -1,23 +1,38 @@ + * + * @see https://www.php.net/manual/en/curl.constants.php#constant.curl-lock-data-connect + */ + public array $shareConnectionOptions = [ + CURL_LOCK_DATA_CONNECT, + CURL_LOCK_DATA_DNS, + ]; + /** * -------------------------------------------------------------------------- * CURLRequest Share Options diff --git a/app/Config/Cache.php b/app/Config/Cache.php index a05ca78c1..0e0dfca9e 100644 --- a/app/Config/Cache.php +++ b/app/Config/Cache.php @@ -3,6 +3,7 @@ namespace Config; use CodeIgniter\Cache\CacheInterface; +use CodeIgniter\Cache\Handlers\ApcuHandler; use CodeIgniter\Cache\Handlers\DummyHandler; use CodeIgniter\Cache\Handlers\FileHandler; use CodeIgniter\Cache\Handlers\MemcachedHandler; @@ -78,7 +79,7 @@ class Cache extends BaseConfig * Your file storage preferences can be specified below, if you are using * the File driver. * - * @var array + * @var array{storePath?: string, mode?: int} */ public array $file = [ 'storePath' => WRITEPATH . 'cache/', @@ -95,7 +96,7 @@ class Cache extends BaseConfig * * @see https://codeigniter.com/user_guide/libraries/caching.html#memcached * - * @var array + * @var array{host?: string, port?: int, weight?: int, raw?: bool} */ public array $memcached = [ 'host' => '127.0.0.1', @@ -108,17 +109,28 @@ class Cache extends BaseConfig * ------------------------------------------------------------------------- * Redis settings * ------------------------------------------------------------------------- + * * Your Redis server can be specified below, if you are using * the Redis or Predis drivers. * - * @var array + * @var array{ + * host?: string, + * password?: string|null, + * port?: int, + * timeout?: int, + * async?: bool, + * persistent?: bool, + * database?: int + * } */ public array $redis = [ - 'host' => '127.0.0.1', - 'password' => null, - 'port' => 6379, - 'timeout' => 0, - 'database' => 0, + 'host' => '127.0.0.1', + 'password' => null, + 'port' => 6379, + 'timeout' => 0, + 'async' => false, // specific to Predis and ignored by the native Redis extension + 'persistent' => false, + 'database' => 0, ]; /** @@ -132,6 +144,7 @@ class Cache extends BaseConfig * @var array> */ public array $validHandlers = [ + 'apcu' => ApcuHandler::class, 'dummy' => DummyHandler::class, 'file' => FileHandler::class, 'memcached' => MemcachedHandler::class, @@ -158,4 +171,28 @@ class Cache extends BaseConfig * @var bool|list */ public $cacheQueryString = false; + + /** + * -------------------------------------------------------------------------- + * Web Page Caching: Cache Status Codes + * -------------------------------------------------------------------------- + * + * HTTP status codes that are allowed to be cached. Only responses with + * these status codes will be cached by the PageCache filter. + * + * Default: [] - Cache all status codes (backward compatible) + * + * Recommended: [200] - Only cache successful responses + * + * You can also use status codes like: + * [200, 404, 410] - Cache successful responses and specific error codes + * [200, 201, 202, 203, 204] - All 2xx successful responses + * + * WARNING: Using [] may cache temporary error pages (404, 500, etc). + * Consider restricting to [200] for production applications to avoid + * caching errors that should be temporary. + * + * @var list + */ + public array $cacheStatusCodes = []; } diff --git a/app/Config/ContentSecurityPolicy.php b/app/Config/ContentSecurityPolicy.php index 5a46774af..bdf7c2ba1 100644 --- a/app/Config/ContentSecurityPolicy.php +++ b/app/Config/ContentSecurityPolicy.php @@ -30,6 +30,11 @@ class ContentSecurityPolicy extends BaseConfig */ public ?string $reportURI = null; + /** + * Specifies a reporting endpoint to which violation reports ought to be sent. + */ + public ?string $reportTo = null; + /** * Instructs user agents to rewrite URL schemes, changing * HTTP to HTTPS. This directive is for websites with @@ -38,12 +43,12 @@ class ContentSecurityPolicy extends BaseConfig public bool $upgradeInsecureRequests = false; // ------------------------------------------------------------------------- - // Sources allowed + // CSP DIRECTIVES SETTINGS // NOTE: once you set a policy to 'none', it cannot be further restricted // ------------------------------------------------------------------------- /** - * Will default to self if not overridden + * Will default to `'self'` if not overridden * * @var list|string|null */ @@ -64,6 +69,21 @@ class ContentSecurityPolicy extends BaseConfig 'www.google.com www.gstatic.com' ]; + /** + * Specifies valid sources for JavaScript - - + + + - -
-
- Displayed at — - PHP: — - CodeIgniter: -- - Environment: + +
+
+ Displayed at — + PHP: — + CodeIgniter: -- + Environment: +
+
+

getCode() ? ' #' . $exception->getCode() : '') ?>

+

+ getMessage())) ?> + getMessage())) ?>" + rel="noreferrer" target="_blank">search → +

+
+ +
-

getCode() ? ' #' . $exception->getCode() : '') ?>

-

- getMessage())) ?> - getMessage())) ?>" - rel="noreferrer" target="_blank">search → -

+

at line

+ + +
+ +
+
-
- -
-

at line

- - -
- -
- -
- -
- getPrevious()) { - $last = $prevException; - ?> - -
-                Caused by:
-                getCode() ? ' #' . $prevException->getCode() : '') ?>
-
-                getMessage())) ?>
-                getMessage())) ?>" rel="noreferrer" target="_blank">search →
-                getFile()) . ':' . $prevException->getLine()) ?>
-            
- - -
- - -
- - - -
- - -
- -
    - $row) : ?> - -
  1. -

    - - - - - {PHP internal code} - - - - -   —   - - - ( arguments ) -

    - - - getParameters(); - } - - foreach ($row['args'] as $key => $value) : ?> - - - - - - -
    name : "#{$key}") ?>
    -
    - - () - - - - -   —   () - -

    - - - -
    - -
    - -
  2. - - -
- -
- - -
- - - -

$

- - - - - - - - - - $value) : ?> - - - - - - -
KeyValue
- - - -
- -
- - - - - - -

Constants

- - - - - - - - - - $value) : ?> - - - - - - -
KeyValue
- - - -
- -
- -
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PathgetUri()) ?>
HTTP MethodgetMethod()) ?>
IP AddressgetIPAddress()) ?>
Is AJAX Request?isAJAX() ? 'yes' : 'no' ?>
Is CLI Request?isCLI() ? 'yes' : 'no' ?>
Is Secure Request?isSecure() ? 'yes' : 'no' ?>
User AgentgetUserAgent()->getAgentString()) ?>
- - - - - - - - -

$

- - - - - - - - - - $value) : ?> - - - - - - -
KeyValue
- - - -
- -
- - - - - -
- No $_GET, $_POST, or $_COOKIE Information to show. -
- - - - headers(); ?> - - -

Headers

- - - - - - - - - - $value) : ?> - - - - - - -
HeaderValue
- getValueLine(), 'html'); - } else { - foreach ($value as $i => $header) { - echo ' ('. $i+1 . ') ' . esc($header->getValueLine(), 'html'); - } - } - ?> -
- - -
- - +
setStatusCode(http_response_code()); - ?> -
- - - - - -
Response StatusgetStatusCode() . ' - ' . $response->getReasonPhrase()) ?>
+ $last = $exception; - headers(); ?> - -

Headers

+ while ($prevException = $last->getPrevious()) { + $last = $prevException; + ?> + +
+        Caused by:
+        getCode() ? ' #' . $prevException->getCode() : '') ?>
+
+        getMessage())) ?>
+        getMessage())) ?>"
+           rel="noreferrer" target="_blank">search →
+        getFile()) . ':' . $prevException->getLine()) ?>
+        
+ + +
+ + +
+ + + +
+ + +
+ +
    + $row) : ?> + +
  1. +

    + + + + + {PHP internal code} + + + + +   —   + + + ( arguments ) +

    + + + getParameters(); + } + + foreach ($row['args'] as $key => $value) : ?> + + + + + + +
    name : "#{$key}") ?>
    +
    + + () + + + + +   —   () + +

    + + + +
    + +
    + +
  2. + + +
+ +
+ + +
+ + + +

$

+ + + + + + + + + + $value) : ?> + + + + + + +
KeyValue
+ + + +
+ +
+ + + + + + +

Constants

+ + + + + + + + + + $value) : ?> + + + + + + +
KeyValue
+ + + +
+ +
+ +
+ + +
+ - - - - - - - $value) : ?> - - + + - + + + + + + + + + + + + + + + + + + + + + + + + +
HeaderValue
- getHeaderLine($name), 'html'); - } else { - foreach ($value as $i => $header) { - echo ' ('. $i+1 . ') ' . esc($header->getValueLine(), 'html'); - } - } - ?> - PathgetUri()) ?>
HTTP MethodgetMethod()) ?>
IP AddressgetIPAddress()) ?>
Is AJAX Request?isAJAX() ? 'yes' : 'no' ?>
Is CLI Request?isCLI() ? 'yes' : 'no' ?>
Is Secure Request?isSecure() ? 'yes' : 'no' ?>
User AgentgetUserAgent()->getAgentString()) ?>
- -
- -
- + + + -
    - -
  1. - -
-
+ - -
+

$

- - +
+ + + + + + + + $value) : ?> + + + + + + +
KeyValue
+ + + +
+ +
+ + + + + +
+ No $_GET, $_POST, or $_COOKIE Information to show. +
+ + + + headers(); ?> + + +

Headers

+ + + + + + + + + + $value) : ?> + + + + + + +
HeaderValue
+ getValueLine(), 'html'); + } else { + foreach ($value as $i => $header) { + echo ' (' . ($i + 1) . ') ' . esc($header->getValueLine(), 'html'); + } + } + ?> +
+ + +
+ + + setStatusCode(http_response_code()); + ?> +
+ - - + + - - - - - - - - - -
Memory UsageResponse StatusgetStatusCode() . ' - ' . $response->getReasonPhrase()) ?>
Peak Memory Usage:
Memory Limit:
+ -
+ headers(); ?> + +

Headers

-
+ + + + + + + + + $value) : ?> + + + + + + +
HeaderValue
+ getHeaderLine($name), 'html'); + } else { + foreach ($value as $i => $header) { + echo ' (' . ($i + 1) . ') ' . esc($header->getValueLine(), 'html'); + } + } + ?> +
-
- + +
- + +
+ + +
    + +
  1. + +
+
+ + +
+ + + + + + + + + + + + + + + + +
Memory Usage
Peak Memory Usage:
Memory Limit:
+ +
+ +
+ +
+ + + diff --git a/app/Views/expenses/manage.php b/app/Views/expenses/manage.php index f3bfd12dd..945429d7d 100644 --- a/app/Views/expenses/manage.php +++ b/app/Views/expenses/manage.php @@ -49,13 +49,13 @@ }); }); -> + + false, 'selected_printer' => 'takings_printer']) ?>
- - '; - exit; - } - ?> - -
- - - - - - - - - - -
- -
-
- -
- - - - - - - - - - 0) { ?> - - - - - -
-
- - - - - - - - - 0) { - $invoice_columns = $invoice_columns + 1; - ?> - - - - - - $item) { - if ($item['print_option'] == PRINT_YES) { - ?> - - - - - - - 0): ?> - - - - - + + <?= lang('Sales.email_receipt') ?> + + + + ' . esc($error_message) . ''; + exit; } - ?> + ?> - - - +
+ +
+ + + + + + + + +
+ +
+
+ +
+ + + + + + + + + + 0) { ?> + + + + + +
+
- - - - - + + + + + + + + 0) { + $invoice_columns = $invoice_columns + 1; + ?> + + + + + + $item) { + if ($item['print_option'] == PRINT_YES) { + ?> + + + + + + + 0): ?> + + + + + + + + + - $tax) { ?> - - + + - - - - - - + $tax) { ?> + + + + + + - $payment) { - $only_sale_check |= $payment['payment_type'] == lang('Sales.check'); - $splitpayment = explode(':', $payment['payment_type']); // TODO: $splitpayment does not meet the variable naming conventions for this project - $show_giftcard_remainder |= $splitpayment[0] == lang('Sales.giftcard'); - ?> - - + + - - - - - - - - + - - - - - - -
= 0 ? ($only_sale_check ? 'Sales.check_balance' : 'Sales.change_due') : 'Sales.amount_due') ?>
+ foreach ($payments as $payment_id => $payment) { + $only_sale_check |= $payment['payment_type'] == lang('Sales.check'); + $splitpayment = explode(':', $payment['payment_type']); // TODO: $splitpayment does not meet the variable naming conventions for this project + $show_giftcard_remainder |= $splitpayment[0] == lang('Sales.giftcard'); + ?> + + + + + + -
-
-
- - -
- -
-
- <?= esc($sale_id) ?>
- + + + + + + + + + + + + = 0 ? ($only_sale_check ? 'Sales.check_balance' : 'Sales.change_due') : 'Sales.amount_due') ?> + + + + + +
+
+
+ + +
+ +
+
+ <?= esc($sale_id) ?>
+ +
-
- - - + diff --git a/app/Views/sales/manage.php b/app/Views/sales/manage.php index dc5eacef2..d3d9ceaaf 100644 --- a/app/Views/sales/manage.php +++ b/app/Views/sales/manage.php @@ -73,7 +73,7 @@ false, 'selected_printer' => 'takings_printer']) ?> - +
@@ -116,7 +116,7 @@ ?> - + diff --git a/app/Views/sales/register.php b/app/Views/sales/register.php index 12fc10c08..6b4f275e8 100644 --- a/app/Views/sales/register.php +++ b/app/Views/sales/register.php @@ -173,12 +173,12 @@ helper('url'); - - - diff --git a/app/Views/sales/work_order_email.php b/app/Views/sales/work_order_email.php index 22da1c75d..0156f2ccc 100644 --- a/app/Views/sales/work_order_email.php +++ b/app/Views/sales/work_order_email.php @@ -55,7 +55,7 @@
'item_number', 'id' => 'item_number', 'class' => 'form-control input-sm', 'value' => $item['item_number'], 'tabindex' => ++$tabindex]) ?> + 'name', 'id' => 'name', 'class' => 'form-control input-sm', 'value' => $item['name'], 'tabindex' => ++$tabindex]) ?> +
'hidden', 'name' => 'item_id', 'value' => $item['item_id']]) ?> + 'item_description', 'id' => 'item_description', 'class' => 'form-control input-sm', 'value' => $item['description'], 'tabindex' => ++$tabindex]) ?> - +
@@ -103,7 +103,7 @@ ?> - + diff --git a/app/Views/taxes/tax_categories.php b/app/Views/taxes/tax_categories.php index 64b57e508..5ebc4b98a 100644 --- a/app/Views/taxes/tax_categories.php +++ b/app/Views/taxes/tax_categories.php @@ -79,24 +79,18 @@ $('input[name="tax_category[]"]').each(function() { value_count = $(this).val() == value ? value_count + 1 : value_count; }); - if (value_count > 1) { - return false; - } - return true; + return value_count <= 1; + }, ""); $.validator.addMethod('validateTaxCategoryCharacters', function(value, element) { - if ((value.indexOf('_') != -1)) { - return false; - } - return true; + return (value.indexOf('_') == -1); + }, ""); $.validator.addMethod('requireTaxCategory', function(value, element) { - if (value.trim() == '') { - return false; - } - return true; + return value.trim() != ''; + }, ""); $('#tax_categories_form').validate($.extend(form_support.handler, { @@ -120,8 +114,8 @@ })); $tax_category_data) { + $i = 0; + foreach ($tax_categories as $tax_category => $tax_category_data) { ?> $('').rules("add", { requireTaxCategory: true, diff --git a/app/Views/taxes/tax_codes.php b/app/Views/taxes/tax_codes.php index cae03bff7..5de1b09b4 100644 --- a/app/Views/taxes/tax_codes.php +++ b/app/Views/taxes/tax_codes.php @@ -79,24 +79,18 @@ $("input[name='tax_code[]']").each(function() { value_count = $(this).val() == value ? value_count + 1 : value_count; }); - if (value_count > 1) { - return false; - } - return true; + return value_count <= 1; + }, ""); $.validator.addMethod('validateTaxCodeCharacters', function(value, element) { - if ((value.indexOf('_') != -1)) { - return false; - } - return true; + return (value.indexOf('_') == -1); + }, ""); $.validator.addMethod('requireTaxCode', function(value, element) { - if (value.trim() == '') { - return false; - } - return true; + return value.trim() != ''; + }, ""); $('#tax_codes_form').validate($.extend(form_support.handler, { diff --git a/app/Views/taxes/tax_jurisdictions.php b/app/Views/taxes/tax_jurisdictions.php index 8b5c312a3..63469d3bc 100644 --- a/app/Views/taxes/tax_jurisdictions.php +++ b/app/Views/taxes/tax_jurisdictions.php @@ -83,24 +83,18 @@ $("input[name='jurisdiction_name[]']").each(function() { value_count = $(this).val() == value ? value_count + 1 : value_count; }); - if (value_count > 1) { - return false; - } - return true; + return value_count <= 1; + }, ""); $.validator.addMethod('validateTaxJurisdictionCharacters', function(value, element) { - if ((value.indexOf('_') != -1)) { - return false; - } - return true; + return (value.indexOf('_') == -1); + }, ""); $.validator.addMethod('requireTaxJurisdiction', function(value, element) { - if (value.trim() == '') { - return false; - } - return true; + return value.trim() != ''; + }, ""); $('#tax_jurisdictions_form').validate($.extend(form_support.handler, { diff --git a/composer.json b/composer.json index 296c5b921..9b782a7a8 100644 --- a/composer.json +++ b/composer.json @@ -32,11 +32,11 @@ }, "require": { "ext-intl": "*", - "php": "^8.1", - "codeigniter4/framework": "^4.6.3", + "php": "^8.2", + "codeigniter4/framework": "4.7.2", "dompdf/dompdf": "^2.0.3", "ezyang/htmlpurifier": "^4.17", - "laminas/laminas-escaper": "2.17.0", + "laminas/laminas-escaper": "2.18.0", "paragonie/random_compat": "^2.0.21", "picqer/php-barcode-generator": "^2.4.0", "tamtamchik/namecase": "^3.0.0" diff --git a/composer.lock b/composer.lock index 9817fb506..8a9a1d69a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,40 +4,41 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3fe9e4622879914bfa763b71c236c7fe", + "content-hash": "e95f6e5e86d323370ddb0df57c4d3fb3", "packages": [ { "name": "codeigniter4/framework", - "version": "v4.6.3", + "version": "v4.7.2", "source": { "type": "git", "url": "https://github.com/codeigniter4/framework.git", - "reference": "68d1a5896106f869452dd369a690dd5bc75160fb" + "reference": "b3359be849be29394660c3aed909aa32b6c45cf6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/codeigniter4/framework/zipball/68d1a5896106f869452dd369a690dd5bc75160fb", - "reference": "68d1a5896106f869452dd369a690dd5bc75160fb", + "url": "https://api.github.com/repos/codeigniter4/framework/zipball/b3359be849be29394660c3aed909aa32b6c45cf6", + "reference": "b3359be849be29394660c3aed909aa32b6c45cf6", "shasum": "" }, "require": { "ext-intl": "*", "ext-mbstring": "*", - "laminas/laminas-escaper": "^2.17", - "php": "^8.1", + "laminas/laminas-escaper": "^2.18", + "php": "^8.2", "psr/log": "^3.0" }, "require-dev": { "codeigniter/coding-standard": "^1.7", "fakerphp/faker": "^1.24", "friendsofphp/php-cs-fixer": "^3.47.1", - "kint-php/kint": "^6.0", + "kint-php/kint": "^6.1", "mikey179/vfsstream": "^1.6.12", "nexusphp/cs-config": "^3.6", "phpunit/phpunit": "^10.5.16 || ^11.2", "predis/predis": "^3.0" }, "suggest": { + "ext-apcu": "If you use Cache class ApcuHandler", "ext-curl": "If you use CURLRequest class", "ext-dom": "If you use TestResponse", "ext-exif": "If you run Image class tests", @@ -49,7 +50,9 @@ "ext-memcached": "If you use Cache class MemcachedHandler with Memcached", "ext-mysqli": "If you use MySQL", "ext-oci8": "If you use Oracle Database", + "ext-pcntl": "If you use Signals", "ext-pgsql": "If you use PostgreSQL", + "ext-posix": "If you use Signals", "ext-readline": "Improves CLI::input() usability", "ext-redis": "If you use Cache class RedisHandler", "ext-simplexml": "If you format XML", @@ -78,7 +81,7 @@ "slack": "https://codeigniterchat.slack.com", "source": "https://github.com/codeigniter4/CodeIgniter4" }, - "time": "2025-08-02T13:36:13+00:00" + "time": "2026-03-24T18:26:09+00:00" }, { "name": "dompdf/dompdf", @@ -205,32 +208,32 @@ }, { "name": "laminas/laminas-escaper", - "version": "2.17.0", + "version": "2.18.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-escaper.git", - "reference": "df1ef9503299a8e3920079a16263b578eaf7c3ba" + "reference": "06f211dfffff18d91844c1f55250d5d13c007e18" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/df1ef9503299a8e3920079a16263b578eaf7c3ba", - "reference": "df1ef9503299a8e3920079a16263b578eaf7c3ba", + "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/06f211dfffff18d91844c1f55250d5d13c007e18", + "reference": "06f211dfffff18d91844c1f55250d5d13c007e18", "shasum": "" }, "require": { "ext-ctype": "*", "ext-mbstring": "*", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "conflict": { "zendframework/zend-escaper": "*" }, "require-dev": { - "infection/infection": "^0.29.8", - "laminas/laminas-coding-standard": "~3.0.1", - "phpunit/phpunit": "^10.5.45", - "psalm/plugin-phpunit": "^0.19.2", - "vimeo/psalm": "^6.6.2" + "infection/infection": "^0.31.0", + "laminas/laminas-coding-standard": "~3.1.0", + "phpunit/phpunit": "^11.5.42", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13.1" }, "type": "library", "autoload": { @@ -262,7 +265,7 @@ "type": "community_bridge" } ], - "time": "2025-05-06T19:29:36+00:00" + "time": "2025-10-14T18:31:13+00:00" }, { "name": "masterminds/html5", @@ -760,28 +763,28 @@ }, { "name": "codeigniter/coding-standard", - "version": "v1.8.8", + "version": "v1.9.1", "source": { "type": "git", "url": "https://github.com/CodeIgniter/coding-standard.git", - "reference": "410526fc1447a04fcdf5441b9c8507668b97b3a7" + "reference": "c7d227d3d3d0f2270405c8317c5e8c55f2262956" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CodeIgniter/coding-standard/zipball/410526fc1447a04fcdf5441b9c8507668b97b3a7", - "reference": "410526fc1447a04fcdf5441b9c8507668b97b3a7", + "url": "https://api.github.com/repos/CodeIgniter/coding-standard/zipball/c7d227d3d3d0f2270405c8317c5e8c55f2262956", + "reference": "c7d227d3d3d0f2270405c8317c5e8c55f2262956", "shasum": "" }, "require": { "ext-tokenizer": "*", - "friendsofphp/php-cs-fixer": "^3.76", - "nexusphp/cs-config": "^3.26", - "php": "^8.1" + "friendsofphp/php-cs-fixer": "^3.94", + "nexusphp/cs-config": "^3.28", + "php": "^8.2" }, "require-dev": { - "nexusphp/tachycardia": "^2.3", - "phpstan/phpstan": "^2.0", - "phpunit/phpunit": "^10.5 || ^11.2" + "nexusphp/tachycardia": "^2.4", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^11.5 || ^12.5" }, "type": "library", "autoload": { @@ -810,7 +813,7 @@ "slack": "https://codeigniterchat.slack.com", "source": "https://github.com/CodeIgniter/coding-standard" }, - "time": "2025-09-27T13:54:11+00:00" + "time": "2026-02-17T18:41:24+00:00" }, { "name": "composer/pcre", @@ -1207,16 +1210,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.90.0", + "version": "v3.94.2", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "ad732c2e9299c9743f9c55ae53cc0e7642ab1155" + "reference": "7787ceff91365ba7d623ec410b8f429cdebb4f63" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/ad732c2e9299c9743f9c55ae53cc0e7642ab1155", - "reference": "ad732c2e9299c9743f9c55ae53cc0e7642ab1155", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/7787ceff91365ba7d623ec410b8f429cdebb4f63", + "reference": "7787ceff91365ba7d623ec410b8f429cdebb4f63", "shasum": "" }, "require": { @@ -1233,7 +1236,7 @@ "react/event-loop": "^1.5", "react/socket": "^1.16", "react/stream": "^1.4", - "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0", "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", @@ -1247,17 +1250,18 @@ "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" }, "require-dev": { - "facile-it/paraunit": "^1.3.1 || ^2.7", - "infection/infection": "^0.31.0", - "justinrainbow/json-schema": "^6.5", - "keradus/cli-executor": "^2.2", + "facile-it/paraunit": "^1.3.1 || ^2.7.1", + "infection/infection": "^0.32.3", + "justinrainbow/json-schema": "^6.6.4", + "keradus/cli-executor": "^2.3", "mikey179/vfsstream": "^1.6.12", - "php-coveralls/php-coveralls": "^2.9", - "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.6", - "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.6", - "phpunit/phpunit": "^9.6.25 || ^10.5.53 || ^11.5.34", - "symfony/var-dumper": "^5.4.48 || ^6.4.24 || ^7.3.2 || ^8.0", - "symfony/yaml": "^5.4.45 || ^6.4.24 || ^7.3.2 || ^8.0" + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.7", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.7", + "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.51", + "symfony/polyfill-php85": "^1.33", + "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.4", + "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.1" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -1272,7 +1276,7 @@ "PhpCsFixer\\": "src/" }, "exclude-from-classmap": [ - "src/Fixer/Internal/*" + "src/**/Internal/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1298,7 +1302,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.90.0" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.94.2" }, "funding": [ { @@ -1306,7 +1310,7 @@ "type": "github" } ], - "time": "2025-11-20T15:15:16+00:00" + "time": "2026-02-20T16:13:53+00:00" }, { "name": "kint-php/kint", @@ -1487,33 +1491,30 @@ }, { "name": "nexusphp/cs-config", - "version": "v3.26.4", + "version": "v3.28.1", "source": { "type": "git", "url": "https://github.com/NexusPHP/cs-config.git", - "reference": "21cddae9917ec7b98e0b6890540222b658a4bf6e" + "reference": "4175b75a053b35dc64f37727df526520efb456f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/NexusPHP/cs-config/zipball/21cddae9917ec7b98e0b6890540222b658a4bf6e", - "reference": "21cddae9917ec7b98e0b6890540222b658a4bf6e", + "url": "https://api.github.com/repos/NexusPHP/cs-config/zipball/4175b75a053b35dc64f37727df526520efb456f8", + "reference": "4175b75a053b35dc64f37727df526520efb456f8", "shasum": "" }, "require": { "ext-tokenizer": "*", - "friendsofphp/php-cs-fixer": "^3.84", - "php": "^8.1" - }, - "conflict": { - "liaison/cs-config": "*" + "friendsofphp/php-cs-fixer": "^3.94", + "php": "^8.2" }, "require-dev": { - "nexusphp/tachycardia": "^2.1", + "nexusphp/tachycardia": "^2.4", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2.1", "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^10.5 || ^11.0" + "phpunit/phpunit": "^11.5 || ^12.5" }, "type": "library", "autoload": { @@ -1537,20 +1538,20 @@ "slack": "https://nexusphp.slack.com", "source": "https://github.com/NexusPHP/cs-config.git" }, - "time": "2025-09-27T13:51:19+00:00" + "time": "2026-02-22T12:03:01+00:00" }, { "name": "nikic/php-parser", - "version": "v5.6.2", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -1593,9 +1594,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2025-10-21T19:32:17+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "phar-io/manifest", @@ -1717,35 +1718,35 @@ }, { "name": "phpunit/php-code-coverage", - "version": "11.0.11", + "version": "11.0.12", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4" + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", - "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.4.0", + "nikic/php-parser": "^5.7.0", "php": ">=8.2", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-text-template": "^4.0.1", "sebastian/code-unit-reverse-lookup": "^4.0.1", "sebastian/complexity": "^4.0.1", - "sebastian/environment": "^7.2.0", + "sebastian/environment": "^7.2.1", "sebastian/lines-of-code": "^3.0.1", "sebastian/version": "^5.0.2", - "theseer/tokenizer": "^1.2.3" + "theseer/tokenizer": "^1.3.1" }, "require-dev": { - "phpunit/phpunit": "^11.5.2" + "phpunit/phpunit": "^11.5.46" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -1783,7 +1784,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.11" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" }, "funding": [ { @@ -1803,32 +1804,32 @@ "type": "tidelift" } ], - "time": "2025-08-27T14:37:49+00:00" + "time": "2025-12-24T07:01:01+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "5.1.0", + "version": "5.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -1856,15 +1857,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2024-08-27T05:02:59+00:00" + "time": "2026-02-02T13:52:54+00:00" }, { "name": "phpunit/php-invoker", @@ -2052,16 +2065,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.44", + "version": "11.5.55", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "c346885c95423eda3f65d85a194aaa24873cda82" + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c346885c95423eda3f65d85a194aaa24873cda82", - "reference": "c346885c95423eda3f65d85a194aaa24873cda82", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", "shasum": "" }, "require": { @@ -2075,19 +2088,20 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.2", - "phpunit/php-code-coverage": "^11.0.11", - "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", "phpunit/php-invoker": "^5.0.1", "phpunit/php-text-template": "^4.0.1", "phpunit/php-timer": "^7.0.1", "sebastian/cli-parser": "^3.0.2", "sebastian/code-unit": "^3.0.3", - "sebastian/comparator": "^6.3.2", + "sebastian/comparator": "^6.3.3", "sebastian/diff": "^6.0.2", "sebastian/environment": "^7.2.1", "sebastian/exporter": "^6.3.2", "sebastian/global-state": "^7.0.2", "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", "sebastian/type": "^5.1.3", "sebastian/version": "^5.0.2", "staabm/side-effects-detector": "^1.0.5" @@ -2133,7 +2147,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.44" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" }, "funding": [ { @@ -2157,7 +2171,7 @@ "type": "tidelift" } ], - "time": "2025-11-13T07:17:35+00:00" + "time": "2026-02-18T12:37:06+00:00" }, { "name": "predis/predis", @@ -2398,16 +2412,16 @@ }, { "name": "react/child-process", - "version": "v0.6.6", + "version": "v0.6.7", "source": { "type": "git", "url": "https://github.com/reactphp/child-process.git", - "reference": "1721e2b93d89b745664353b9cfc8f155ba8a6159" + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/child-process/zipball/1721e2b93d89b745664353b9cfc8f155ba8a6159", - "reference": "1721e2b93d89b745664353b9cfc8f155ba8a6159", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", "shasum": "" }, "require": { @@ -2461,7 +2475,7 @@ ], "support": { "issues": "https://github.com/reactphp/child-process/issues", - "source": "https://github.com/reactphp/child-process/tree/v0.6.6" + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" }, "funding": [ { @@ -2469,7 +2483,7 @@ "type": "open_collective" } ], - "time": "2025-01-01T16:37:48+00:00" + "time": "2025-12-23T15:25:20+00:00" }, { "name": "react/dns", @@ -3022,16 +3036,16 @@ }, { "name": "sebastian/comparator", - "version": "6.3.2", + "version": "6.3.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8" + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8", - "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", "shasum": "" }, "require": { @@ -3090,7 +3104,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2" + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" }, "funding": [ { @@ -3110,7 +3124,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T08:07:46+00:00" + "time": "2026-01-24T09:26:40+00:00" }, { "name": "sebastian/complexity", @@ -3890,16 +3904,16 @@ }, { "name": "symfony/console", - "version": "v7.3.6", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "c28ad91448f86c5f6d9d2c70f0cf68bf135f252a" + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/c28ad91448f86c5f6d9d2c70f0cf68bf135f252a", - "reference": "c28ad91448f86c5f6d9d2c70f0cf68bf135f252a", + "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", "shasum": "" }, "require": { @@ -3907,7 +3921,7 @@ "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2" + "symfony/string": "^7.2|^8.0" }, "conflict": { "symfony/dependency-injection": "<6.4", @@ -3921,16 +3935,16 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/stopwatch": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -3964,7 +3978,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.6" + "source": "https://github.com/symfony/console/tree/v7.4.7" }, "funding": [ { @@ -3984,7 +3998,7 @@ "type": "tidelift" } ], - "time": "2025-11-04T01:21:42+00:00" + "time": "2026-03-06T14:06:20+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4055,16 +4069,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v7.3.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191" + "reference": "dc2c0eba1af673e736bb851d747d266108aea746" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b7dc69e71de420ac04bc9ab830cf3ffebba48191", - "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/dc2c0eba1af673e736bb851d747d266108aea746", + "reference": "dc2c0eba1af673e736bb851d747d266108aea746", "shasum": "" }, "require": { @@ -4081,13 +4095,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/error-handler": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0" + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4115,7 +4130,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.3" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.4" }, "funding": [ { @@ -4135,7 +4150,7 @@ "type": "tidelift" } ], - "time": "2025-08-13T11:49:31+00:00" + "time": "2026-01-05T11:45:34+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -4215,16 +4230,16 @@ }, { "name": "symfony/filesystem", - "version": "v7.3.6", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "e9bcfd7837928ab656276fe00464092cc9e1826a" + "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/e9bcfd7837928ab656276fe00464092cc9e1826a", - "reference": "e9bcfd7837928ab656276fe00464092cc9e1826a", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/3ebc794fa5315e59fd122561623c2e2e4280538e", + "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e", "shasum": "" }, "require": { @@ -4233,7 +4248,7 @@ "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/process": "^6.4|^7.0" + "symfony/process": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4261,7 +4276,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.3.6" + "source": "https://github.com/symfony/filesystem/tree/v7.4.6" }, "funding": [ { @@ -4281,27 +4296,27 @@ "type": "tidelift" } ], - "time": "2025-11-05T09:52:27+00:00" + "time": "2026-02-25T16:50:00+00:00" }, { "name": "symfony/finder", - "version": "v7.3.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "9f696d2f1e340484b4683f7853b273abff94421f" + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/9f696d2f1e340484b4683f7853b273abff94421f", - "reference": "9f696d2f1e340484b4683f7853b273abff94421f", + "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0" + "symfony/filesystem": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4329,7 +4344,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.3.5" + "source": "https://github.com/symfony/finder/tree/v7.4.6" }, "funding": [ { @@ -4349,20 +4364,20 @@ "type": "tidelift" } ], - "time": "2025-10-15T18:45:57+00:00" + "time": "2026-01-29T09:40:50+00:00" }, { "name": "symfony/options-resolver", - "version": "v7.3.3", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d" + "reference": "b38026df55197f9e39a44f3215788edf83187b80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/0ff2f5c3df08a395232bbc3c2eb7e84912df911d", - "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b38026df55197f9e39a44f3215788edf83187b80", + "reference": "b38026df55197f9e39a44f3215788edf83187b80", "shasum": "" }, "require": { @@ -4400,7 +4415,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.3.3" + "source": "https://github.com/symfony/options-resolver/tree/v7.4.0" }, "funding": [ { @@ -4420,7 +4435,7 @@ "type": "tidelift" } ], - "time": "2025-08-05T10:16:07+00:00" + "time": "2025-11-12T15:39:26+00:00" }, { "name": "symfony/polyfill-ctype", @@ -5003,16 +5018,16 @@ }, { "name": "symfony/process", - "version": "v7.3.4", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b" + "reference": "608476f4604102976d687c483ac63a79ba18cc97" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/f24f8f316367b30810810d4eb30c543d7003ff3b", - "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b", + "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", + "reference": "608476f4604102976d687c483ac63a79ba18cc97", "shasum": "" }, "require": { @@ -5044,7 +5059,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.3.4" + "source": "https://github.com/symfony/process/tree/v7.4.5" }, "funding": [ { @@ -5064,7 +5079,7 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:12:26+00:00" + "time": "2026-01-26T15:07:59+00:00" }, { "name": "symfony/service-contracts", @@ -5155,16 +5170,16 @@ }, { "name": "symfony/stopwatch", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "5a49289e2b308214c8b9c2fda4ea454d8b8ad7cd" + "reference": "8a24af0a2e8a872fb745047180649b8418303084" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/5a49289e2b308214c8b9c2fda4ea454d8b8ad7cd", - "reference": "5a49289e2b308214c8b9c2fda4ea454d8b8ad7cd", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/8a24af0a2e8a872fb745047180649b8418303084", + "reference": "8a24af0a2e8a872fb745047180649b8418303084", "shasum": "" }, "require": { @@ -5197,7 +5212,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v7.3.0" + "source": "https://github.com/symfony/stopwatch/tree/v7.4.0" }, "funding": [ { @@ -5208,31 +5223,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-24T10:49:57+00:00" + "time": "2025-08-04T07:05:15+00:00" }, { "name": "symfony/string", - "version": "v7.3.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "f96476035142921000338bad71e5247fbc138872" + "reference": "9f209231affa85aa930a5e46e6eb03381424b30b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f96476035142921000338bad71e5247fbc138872", - "reference": "f96476035142921000338bad71e5247fbc138872", + "url": "https://api.github.com/repos/symfony/string/zipball/9f209231affa85aa930a5e46e6eb03381424b30b", + "reference": "9f209231affa85aa930a5e46e6eb03381424b30b", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-grapheme": "~1.33", "symfony/polyfill-intl-normalizer": "~1.0", "symfony/polyfill-mbstring": "~1.0" }, @@ -5240,11 +5260,11 @@ "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.1", - "symfony/http-client": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0" + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -5283,7 +5303,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.3.4" + "source": "https://github.com/symfony/string/tree/v7.4.6" }, "funding": [ { @@ -5303,7 +5323,7 @@ "type": "tidelift" } ], - "time": "2025-09-11T14:36:48+00:00" + "time": "2026-02-09T09:33:46+00:00" }, { "name": "theseer/tokenizer", @@ -5358,13 +5378,13 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { "ext-intl": "*", "php": "^8.1" }, - "platform-dev": [], - "plugin-api-version": "2.3.0" + "platform-dev": {}, + "plugin-api-version": "2.6.0" } diff --git a/gulpfile.js b/gulpfile.js index 38388de33..679230508 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -190,6 +190,22 @@ gulp.task('prod-js', function() { }); +// Inject jQuery into login.php (debug mode) - reuse the jQuery file already created by debug-js +gulp.task('debug-login-js', function() { + // Match only core jQuery (jquery-HASH.js), exclude jquery plugins (jquery-HASH.form.js, etc) and jquery-ui (jquery-ui-HASH.js) + // Pattern: jquery-[hash].js where hash is alphanumeric - core jQuery only + var loginDebugJs = gulp.src(['./public/resources/js/jquery-*.js', '!./public/resources/js/jquery-*.form.js', '!./public/resources/js/jquery-*.validate.js', '!./public/resources/js/jquery-ui-*.js']); + return gulp.src('./app/Views/login.php').pipe(inject(loginDebugJs, {addRootSlash: false, ignorePath: '/public/', starttag: ''})).pipe(gulp.dest('./app/Views')); +}); + +// Inject jQuery into login.php (production mode) - reuse the jQuery file already created by prod-js +gulp.task('prod-login-js', function() { + // jQuery prod file is already in resources/jquery-*.min.js from prod-js task + var loginProdJs = gulp.src('./public/resources/jquery-*.min.js'); + return gulp.src('./app/Views/login.php').pipe(inject(loginProdJs, {addRootSlash: false, ignorePath: '/public/', starttag: ''})).pipe(gulp.dest('./app/Views')); +}); + + gulp.task('debug-css', function() { var debugcss = gulp.src(['./node_modules/jquery-ui-dist/jquery-ui.css', @@ -289,6 +305,8 @@ gulp.task('default', 'copy-bootstrap', 'debug-js', 'prod-js', + 'debug-login-js', + 'prod-login-js', 'debug-css', 'prod-css', 'copy-fonts', diff --git a/package-lock.json b/package-lock.json index 5ca1336d6..e50d0ca52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3886,9 +3886,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, diff --git a/preload.php b/preload.php index 0b4531223..288b30b4e 100644 --- a/preload.php +++ b/preload.php @@ -9,6 +9,9 @@ * the LICENSE file that was distributed with this source code. */ +use CodeIgniter\Boot; +use Config\Paths; + /* *--------------------------------------------------------------- * Sample file for Preloading @@ -54,6 +57,7 @@ class preload '/system/Config/Routes.php', '/system/Language/', '/system/bootstrap.php', + '/system/util_bootstrap.php', '/system/rewrite.php', '/Views/', // Errors occur. @@ -69,10 +73,10 @@ class preload private function loadAutoloader(): void { - $paths = new Config\Paths(); + $paths = new Paths(); require rtrim($paths->systemDirectory, '\\/ ') . DIRECTORY_SEPARATOR . 'Boot.php'; - CodeIgniter\Boot::preload($paths); + Boot::preload($paths); } /** @@ -97,7 +101,9 @@ class preload } require_once $file[0]; - echo 'Loaded: ' . $file[0] . "\n"; + // Uncomment only for debugging (to inspect which files are included). + // Never use this in production - preload scripts must not generate output. + // echo 'Loaded: ' . $file[0] . "\n"; } } } diff --git a/tests/Config/AppTest.php b/tests/Config/AppTest.php index 04701c24f..601378ca7 100644 --- a/tests/Config/AppTest.php +++ b/tests/Config/AppTest.php @@ -15,6 +15,10 @@ class AppTest extends CIUnitTestCase protected function tearDown(): void { parent::tearDown(); + // Clean up environment + putenv('CI_ENVIRONMENT'); + putenv('app.allowedHostnames'); + unset($_SERVER['HTTP_HOST']); } public function testGetValidHostReturnsHostWhenValid(): void @@ -59,8 +63,11 @@ class AppTest extends CIUnitTestCase $this->assertEquals('example.com', $host); } - public function testGetValidHostReturnsLocalhostWhenNoWhitelist(): void + public function testGetValidHostReturnsLocalhostInDevelopmentWhenNoWhitelist(): void { + // Set development environment + putenv('CI_ENVIRONMENT=development'); + $app = new class extends App { public array $allowedHostnames = []; @@ -80,6 +87,28 @@ class AppTest extends CIUnitTestCase $this->assertEquals('localhost', $host); } + public function testGetValidHostThrowsExceptionInProductionWhenNoWhitelist(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('allowedHostnames is not configured'); + + // Set production environment + putenv('CI_ENVIRONMENT=production'); + + $app = new class extends App { + public array $allowedHostnames = []; + + public function __construct() {} + }; + + $reflection = new \ReflectionClass($app); + $method = $reflection->getMethod('getValidHost'); + $method->setAccessible(true); + + $_SERVER['HTTP_HOST'] = 'malicious.com'; + $method->invoke($app); + } + public function testGetValidHostHandlesMissingHttpHost(): void { $app = new class extends App { @@ -123,4 +152,133 @@ class AppTest extends CIUnitTestCase $this->assertStringContainsString('example.com', $app->baseURL); $this->assertStringNotContainsString('malicious.com', $app->baseURL); } + + public function testEnvAllowedHostnamesParsedAsCommaSeparated(): void + { + // Set environment variable + putenv('app.allowedHostnames=example.com,www.example.com,demo.example.com'); + + $_SERVER['HTTP_HOST'] = 'www.example.com'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + $_SERVER['HTTPS'] = null; + + $app = new App(); + + // Constructor should parse comma-separated values + $this->assertEquals(['example.com', 'www.example.com', 'demo.example.com'], $app->allowedHostnames); + $this->assertStringContainsString('www.example.com', $app->baseURL); + + // Clean up + putenv('app.allowedHostnames'); + } + + public function testEnvAllowedHostnamesTrimmedWhitespace(): void + { + // Set environment variable with whitespace + putenv('app.allowedHostnames= example.com , www.example.com , demo.example.com '); + + $_SERVER['HTTP_HOST'] = 'example.com'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + $_SERVER['HTTPS'] = null; + + $app = new App(); + + // Values should be trimmed + $this->assertEquals(['example.com', 'www.example.com', 'demo.example.com'], $app->allowedHostnames); + + // Clean up + putenv('app.allowedHostnames'); + } + + public function testEnvAllowedHostnamesSingleValue(): void + { + // Set environment variable with single value + putenv('app.allowedHostnames=localhost'); + + $_SERVER['HTTP_HOST'] = 'localhost'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + $_SERVER['HTTPS'] = null; + + $app = new App(); + + // Single value should work + $this->assertEquals(['localhost'], $app->allowedHostnames); + $this->assertStringContainsString('localhost', $app->baseURL); + + // Clean up + putenv('app.allowedHostnames'); + } + + public function testEnvAllowedHostnamesEmptyStringNotConfigured(): void + { + // Set environment variable to empty string + putenv('app.allowedHostnames='); + + // Set development environment + putenv('CI_ENVIRONMENT=development'); + + $_SERVER['HTTP_HOST'] = 'example.com'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + $_SERVER['HTTPS'] = null; + + $app = new App(); + + // Empty string should be treated as not configured + $this->assertEquals([], $app->allowedHostnames); + + // In development, should fall back to localhost + $this->assertStringContainsString('localhost', $app->baseURL); + + // Clean up + putenv('app.allowedHostnames'); + putenv('CI_ENVIRONMENT'); + } + + public function testEnvAllowedHostnamesFiltersEmptyEntries(): void + { + // Trailing comma should not produce empty entry + putenv('app.allowedHostnames=example.com,'); + $_SERVER['HTTP_HOST'] = 'example.com'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + $_SERVER['HTTPS'] = null; + + $app = new App(); + $this->assertEquals(['example.com'], $app->allowedHostnames); + + // Clean up + putenv('app.allowedHostnames'); + + // Leading comma should not produce empty entry + putenv('app.allowedHostnames=,example.com'); + $_SERVER['HTTP_HOST'] = 'example.com'; + + $app = new App(); + $this->assertEquals(['example.com'], $app->allowedHostnames); + + // Clean up + putenv('app.allowedHostnames'); + + // Whitespace-only entry should be filtered + putenv('app.allowedHostnames=example.com, ,www.example.com'); + $_SERVER['HTTP_HOST'] = 'example.com'; + + $app = new App(); + $this->assertEquals(['example.com', 'www.example.com'], $app->allowedHostnames); + + // Clean up + putenv('app.allowedHostnames'); + + // All-whitespace value should be treated as not configured + putenv('CI_ENVIRONMENT=development'); + putenv('app.allowedHostnames= , , '); + $_SERVER['HTTP_HOST'] = 'example.com'; + + $app = new App(); + $this->assertEquals([], $app->allowedHostnames); + $this->assertStringContainsString('localhost', $app->baseURL); + + // Clean up + putenv('app.allowedHostnames'); + putenv('CI_ENVIRONMENT'); + } } \ No newline at end of file diff --git a/tests/Controllers/ItemsCsvImportTest.php b/tests/Controllers/ItemsCsvImportTest.php index 7c87b8300..d322377b0 100644 --- a/tests/Controllers/ItemsCsvImportTest.php +++ b/tests/Controllers/ItemsCsvImportTest.php @@ -11,13 +11,16 @@ use App\Models\Item_taxes; use App\Models\Attribute; use App\Models\Stock_location; use App\Models\Supplier; +use Config\Database; class ItemsCsvImportTest extends CIUnitTestCase { use DatabaseTestTrait; protected $migrate = true; - protected $migrateOnce = false; + protected $migrateOnce = true; + protected $seed = ''; + protected $seedOnce = true; protected $refresh = true; protected $namespace = null; @@ -29,12 +32,20 @@ class ItemsCsvImportTest extends CIUnitTestCase protected $stock_location; protected $supplier; + public static function setUpBeforeClass(): void + { + $seeder = Database::seeder('tests'); + $seeder->call('TestDatabaseBootstrapSeeder'); + } + + protected function setUp(): void { parent::setUp(); + helper('importfile'); helper('attribute'); - + $this->item = model(Item::class); $this->item_quantity = model(Item_quantity::class); $this->inventory = model(Inventory::class); @@ -51,10 +62,10 @@ class ItemsCsvImportTest extends CIUnitTestCase public function testGenerateCsvHeaderBasic(): void { - $stock_locations = ['Warehouse']; + $stockLocations = ['Warehouse']; $attributes = []; - $csv = generate_import_items_csv($stock_locations, $attributes); + $csv = generate_import_items_csv($stockLocations, $attributes); $this->assertStringContainsString('Id,Barcode,"Item Name"', $csv); $this->assertStringContainsString('Category,"Supplier ID"', $csv); @@ -71,10 +82,10 @@ class ItemsCsvImportTest extends CIUnitTestCase public function testGenerateCsvHeaderMultipleLocations(): void { - $stock_locations = ['Warehouse', 'Store', 'Backroom']; + $stockLocations = ['Warehouse', 'Store', 'Backroom']; $attributes = []; - $csv = generate_import_items_csv($stock_locations, $attributes); + $csv = generate_import_items_csv($stockLocations, $attributes); $this->assertStringContainsString('"location_Warehouse"', $csv); $this->assertStringContainsString('"location_Store"', $csv); @@ -83,10 +94,10 @@ class ItemsCsvImportTest extends CIUnitTestCase public function testGenerateCsvHeaderWithAttributes(): void { - $stock_locations = ['Warehouse']; + $stockLocations = ['Warehouse']; $attributes = ['Color', 'Size', 'Weight']; - $csv = generate_import_items_csv($stock_locations, $attributes); + $csv = generate_import_items_csv($stockLocations, $attributes); $this->assertStringContainsString('"attribute_Color"', $csv); $this->assertStringContainsString('"attribute_Size"', $csv); @@ -123,13 +134,13 @@ class ItemsCsvImportTest extends CIUnitTestCase public function testGetCsvFileBasic(): void { - $csv_content = "Id,Barcode,\"Item Name\",Category,\"Supplier ID\",\"Cost Price\",\"Unit Price\",\"Tax 1 Name\",\"Tax 1 Percent\",\"Tax 2 Name\",\"Tax 2 Percent\",\"Reorder Level\",Description,\"Allow Alt Description\",\"Item has Serial Number\",Image,HSN\n"; - $csv_content .= ",ITEM001,Test Item,Electronics,1,10.00,15.00,,,,,5,Test Description,0,0,,HSN001\n"; + $csvContent = "Id,Barcode,\"Item Name\",Category,\"Supplier ID\",\"Cost Price\",\"Unit Price\",\"Tax 1 Name\",\"Tax 1 Percent\",\"Tax 2 Name\",\"Tax 2 Percent\",\"Reorder Level\",Description,\"Allow Alt Description\",\"Item has Serial Number\",Image,HSN\n"; + $csvContent .= ",ITEM001,Test Item,Electronics,1,10.00,15.00,,,,,5,Test Description,0,0,,HSN001\n"; - $temp_file = tempnam(sys_get_temp_dir(), 'csv_test_'); - file_put_contents($temp_file, $csv_content); + $tempFile = tempnam(sys_get_temp_dir(), 'csv_test_'); + file_put_contents($tempFile, $csvContent); - $rows = get_csv_file($temp_file); + $rows = get_csv_file($tempFile); $this->assertCount(1, $rows); $this->assertEquals('', $rows[0]['Id']); @@ -137,81 +148,81 @@ class ItemsCsvImportTest extends CIUnitTestCase $this->assertEquals('Test Item', $rows[0]['Item Name']); $this->assertEquals('Electronics', $rows[0]['Category']); - unlink($temp_file); + unlink($tempFile); } public function testGetCsvFileWithBom(): void { $bom = pack('CCC', 0xef, 0xbb, 0xbf); - $csv_content = $bom . "Id,\"Item Name\",Category\n"; - $csv_content .= "1,Test Item,Electronics\n"; + $csvContent = $bom . "Id,\"Item Name\",Category\n"; + $csvContent .= "1,Test Item,Electronics\n"; - $temp_file = tempnam(sys_get_temp_dir(), 'csv_test_bom_'); - file_put_contents($temp_file, $csv_content); + $tempFile = tempnam(sys_get_temp_dir(), 'csv_test_bom_'); + file_put_contents($tempFile, $csvContent); - $rows = get_csv_file($temp_file); + $rows = get_csv_file($tempFile); $this->assertCount(1, $rows); $this->assertEquals('1', $rows[0]['Id']); $this->assertEquals('Test Item', $rows[0]['Item Name']); - unlink($temp_file); + unlink($tempFile); } public function testGetCsvFileMultipleRows(): void { - $csv_content = "Id,\"Item Name\",Category\n"; - $csv_content .= "1,Item One,Cat A\n"; - $csv_content .= "2,Item Two,Cat B\n"; - $csv_content .= "3,Item Three,Cat C\n"; + $csvContent = "Id,\"Item Name\",Category\n"; + $csvContent .= "1,Item One,Cat A\n"; + $csvContent .= "2,Item Two,Cat B\n"; + $csvContent .= "3,Item Three,Cat C\n"; - $temp_file = tempnam(sys_get_temp_dir(), 'csv_test_multi_'); - file_put_contents($temp_file, $csv_content); + $tempFile = tempnam(sys_get_temp_dir(), 'csv_test_multi_'); + file_put_contents($tempFile, $csvContent); - $rows = get_csv_file($temp_file); + $rows = get_csv_file($tempFile); $this->assertCount(3, $rows); $this->assertEquals('Item One', $rows[0]['Item Name']); $this->assertEquals('Item Two', $rows[1]['Item Name']); $this->assertEquals('Item Three', $rows[2]['Item Name']); - unlink($temp_file); + unlink($tempFile); } public function testBomExists(): void { $bom = pack('CCC', 0xef, 0xbb, 0xbf); - $content_with_bom = $bom . "test content"; + $contentWithBom = $bom . "test content"; - $temp_file = tempnam(sys_get_temp_dir(), 'bom_test_'); - file_put_contents($temp_file, $content_with_bom); + $tempFile = tempnam(sys_get_temp_dir(), 'bom_test_'); + file_put_contents($tempFile, $contentWithBom); - $handle = fopen($temp_file, 'r'); + $handle = fopen($tempFile, 'r'); $result = bom_exists($handle); fclose($handle); $this->assertTrue($result); - unlink($temp_file); + unlink($tempFile); } public function testBomNotExists(): void { - $content_without_bom = "test content without BOM"; + $contentWithoutBom = "test content without BOM"; - $temp_file = tempnam(sys_get_temp_dir(), 'no_bom_test_'); - file_put_contents($temp_file, $content_without_bom); + $tempFile = tempnam(sys_get_temp_dir(), 'no_bom_test_'); + file_put_contents($tempFile, $contentWithoutBom); - $handle = fopen($temp_file, 'r'); + $handle = fopen($tempFile, 'r'); $result = bom_exists($handle); fclose($handle); $this->assertFalse($result); - unlink($temp_file); + unlink($tempFile); } public function testImportItemBasicFields(): void { - $item_data = [ + $itemData = [ 'item_id' => null, 'name' => 'CSV Imported Item', 'description' => 'Description from CSV', @@ -226,22 +237,28 @@ class ItemsCsvImportTest extends CIUnitTestCase 'deleted' => 0 ]; - $item_id = $this->item->save_value($item_data); + $this->assertTrue($this->item->save_value($itemData)); - $this->assertIsInt($item_id); - $this->assertGreaterThan(0, $item_id); + $row = $this->db->table('items') + ->where('item_number', $itemData['item_number']) + ->get() + ->getRow(); - $saved_item = $this->item->get_info($item_id); - $this->assertEquals('CSV Imported Item', $saved_item->name); - $this->assertEquals('Description from CSV', $saved_item->description); - $this->assertEquals('Electronics', $saved_item->category); - $this->assertEquals(10.50, (float)$saved_item->cost_price); - $this->assertEquals(25.99, (float)$saved_item->unit_price); + $this->assertNotNull($row); + $this->assertIsInt((int) $row->item_id); + $this->assertGreaterThan(0, (int) $row->item_id); + + $savedItem = $this->item->get_info((int) $row->item_id); + $this->assertEquals('CSV Imported Item', $savedItem->name); + $this->assertEquals('Description from CSV', $savedItem->description); + $this->assertEquals('Electronics', $savedItem->category); + $this->assertEquals(10.50, (float) $savedItem->cost_price); + $this->assertEquals(25.99, (float) $savedItem->unit_price); } public function testImportItemWithQuantity(): void { - $item_data = [ + $itemData = [ 'item_id' => null, 'name' => 'Item With Quantity', 'category' => 'Test Category', @@ -251,28 +268,29 @@ class ItemsCsvImportTest extends CIUnitTestCase 'deleted' => 0 ]; - $item_id = $this->item->save_value($item_data); + $this->assertTrue($this->item->save_value($itemData)); - $location_id = 1; + $locationId = 1; $quantity = 100; - $item_quantity_data = [ - 'item_id' => $item_id, - 'location_id' => $location_id, + $itemQuantityData = [ + 'item_id' => $itemData['item_id'], + 'location_id' => $locationId, 'quantity' => $quantity ]; - $result = $this->item_quantity->save_value($item_quantity_data, $item_id, $location_id); + $result = $this->item_quantity->save_value($itemQuantityData, $itemData['item_id'], $locationId); $this->assertTrue($result); - $saved_quantity = $this->item_quantity->get_item_quantity($item_id, $location_id); - $this->assertEquals($quantity, $saved_quantity->quantity); + $savedQuantity = $this->item_quantity->get_item_quantity($itemData['item_id'], $locationId); + $this->assertEquals($quantity, $savedQuantity->quantity); } public function testImportItemCreatesInventoryRecord(): void { - $item_data = [ + $itemData = [ 'item_id' => null, + 'item_number' => '1234567890', 'name' => 'Item With Inventory', 'category' => 'Test', 'cost_price' => 5.00, @@ -280,29 +298,30 @@ class ItemsCsvImportTest extends CIUnitTestCase 'deleted' => 0 ]; - $item_id = $this->item->save_value($item_data); + $this->assertTrue($this->item->save_value($itemData)); - $inventory_data = [ + $inventoryData = [ 'trans_inventory' => 50, - 'trans_items' => $item_id, + 'trans_items' => $itemData['item_id'], 'trans_location' => 1, 'trans_comment' => 'CSV Import', 'trans_user' => 1 ]; - $trans_id = $this->inventory->insert($inventory_data); + $transId = $this->inventory->insert($inventoryData); - $this->assertIsInt($trans_id); - $this->assertGreaterThan(0, $trans_id); + $this->assertIsInt($transId); + $this->assertGreaterThan(0, $transId); - $inventory_records = $this->inventory->get_inventory_data_for_item($item_id, 1); - $this->assertGreaterThanOrEqual(1, $inventory_records->getNumRows()); + $inventoryRecords = $this->inventory->get_inventory_data_for_item($itemData['item_id'], 1); + $this->assertGreaterThanOrEqual(1, $inventoryRecords->getNumRows()); } public function testImportItemWithTaxes(): void { - $item_data = [ + $itemData = [ 'item_id' => null, + 'item_number' => '1234567890', 'name' => 'Taxable Item', 'category' => 'Test', 'cost_price' => 100.00, @@ -310,26 +329,26 @@ class ItemsCsvImportTest extends CIUnitTestCase 'deleted' => 0 ]; - $item_id = $this->item->save_value($item_data); + $this->assertTrue($this->item->save_value($itemData)); - $taxes_data = [ + $taxesData = [ ['name' => 'VAT', 'percent' => 20], ['name' => 'GST', 'percent' => 10] ]; - $result = $this->item_taxes->save_value($taxes_data, $item_id); + $result = $this->item_taxes->save_value($taxesData, $itemData['item_id']); $this->assertTrue($result); - $saved_taxes = $this->item_taxes->get_info($item_id); - - $tax_names = array_column($saved_taxes, 'name'); - $this->assertContains('VAT', $tax_names); - $this->assertContains('GST', $tax_names); + $savedTaxes = $this->item_taxes->get_info($itemData['item_id']); + + $taxNames = array_column($savedTaxes, 'name'); + $this->assertContains('VAT', $taxNames); + $this->assertContains('GST', $taxNames); } public function testImportMultipleItemsFromSimulatedCsv(): void { - $csv_data = [ + $csvData = [ [ 'Id' => '', 'Barcode' => 'ITEM-A', @@ -372,10 +391,8 @@ class ItemsCsvImportTest extends CIUnitTestCase ] ]; - $imported_item_ids = []; - - foreach ($csv_data as $row) { - $item_data = [ + foreach ($csvData as $row) { + $itemData = [ 'item_id' => (int)$row['Id'] ?: null, 'name' => $row['Item Name'], 'description' => $row['Description'], @@ -389,25 +406,23 @@ class ItemsCsvImportTest extends CIUnitTestCase 'deleted' => false ]; - $item_id = $this->item->save_value($item_data); - $imported_item_ids[] = $item_id; + $this->assertTrue($this->item->save_value($itemData)); } - $this->assertCount(2, $imported_item_ids); - - $item1 = $this->item->get_info($imported_item_ids[0]); + $item1 = $this->item->get_info_by_id_or_number('ITEM-A'); $this->assertEquals('First Item', $item1->name); - $this->assertEquals(10.00, (float)$item1->cost_price); + $this->assertEquals(10.00, (float) $item1->cost_price); - $item2 = $this->item->get_info($imported_item_ids[1]); + $item2 = $this->item->get_info_by_id_or_number('ITEM-B'); $this->assertEquals('Second Item', $item2->name); - $this->assertEquals(15.00, (float)$item2->cost_price); + $this->assertEquals(15.00, (float) $item2->cost_price); } public function testImportUpdateExistingItem(): void { - $original_data = [ + $originalData = [ 'item_id' => null, + 'item_number' => '1234567890', 'name' => 'Original Name', 'category' => 'Original Category', 'cost_price' => 10.00, @@ -415,10 +430,10 @@ class ItemsCsvImportTest extends CIUnitTestCase 'deleted' => 0 ]; - $item_id = $this->item->save_value($original_data); + $this->assertTrue($this->item->save_value($originalData)); - $updated_data = [ - 'item_id' => $item_id, + $updatedData = [ + 'item_id' => $originalData['item_id'], 'name' => 'Updated Name', 'category' => 'Updated Category', 'cost_price' => 15.00, @@ -428,19 +443,66 @@ class ItemsCsvImportTest extends CIUnitTestCase 'deleted' => 0 ]; - $this->item->save_value($updated_data); + $this->assertTrue($this->item->save_value($updatedData, $updatedData['item_id'])); - $updated_item = $this->item->get_info($item_id); - $this->assertEquals('Updated Name', $updated_item->name); - $this->assertEquals('Updated Category', $updated_item->category); - $this->assertEquals(15.00, (float)$updated_item->cost_price); - $this->assertEquals(30.00, (float)$updated_item->unit_price); + $updatedItem = $this->item->get_info($updatedData['item_id']); + $this->assertEquals('Updated Name', $updatedItem->name); + $this->assertEquals('Updated Category', $updatedItem->category); + $this->assertEquals(15.00, (float)$updatedItem->cost_price); + $this->assertEquals(30.00, (float)$updatedItem->unit_price); + } + + public function testImportDeleteAttributeFromExistingItem(): void + { + $originalData = [ + 'item_id' => null, + 'item_number' => '1234567890', + 'name' => 'Original Name', + 'category' => 'Original Category', + 'cost_price' => '10.00', + 'unit_price' => '20.00', + 'deleted' => 0 + ]; + + $this->assertTrue($this->item->save_value($originalData)); + + $definitionData = [ + 'definition_name' => 'Color', + 'definition_type' => TEXT, + 'definition_flags' => 0, + 'deleted' => 0 + ]; + + $this->assertTrue($this->attribute->saveDefinition($definitionData)); + + //Assign attribute to item + $attributeValue = 'Red'; + $attributeId = $this->attribute->saveAttributeValue(//need to properly assign an attribute link before this is going to work + $attributeValue, + $definitionData['definition_id'], + $originalData['item_id'], + false, + TEXT + ); + $this->assertGreaterThan(0, $attributeId, 'Attribute fixture must exist before testing _DELETE_'); + + //Mock CSV import + $updatedItemData = ['item_id' => $originalData['item_id']]; + $attributeDefinitionData = [$definitionData]; + $updatedValues = ['Color' => '_DELETE_']; + + $saveSuccess = $this->attribute->saveCSVRowAttributeData($updatedValues, $updatedItemData, $attributeDefinitionData); + $this->assertTrue($saveSuccess); + + $resultingAttribute = $this->attribute->getAttributeValue($originalData['item_id'], $definitionData['definition_id']); + $this->assertEquals(NEW_ENTRY, $resultingAttribute->attribute_id); } public function testImportItemWithAttributeText(): void { - $item_data = [ + $itemData = [ 'item_id' => null, + 'item_number' => '1234567890', 'name' => 'Item With Attribute', 'category' => 'Test', 'cost_price' => 10.00, @@ -448,35 +510,42 @@ class ItemsCsvImportTest extends CIUnitTestCase 'deleted' => 0 ]; - $item_id = $this->item->save_value($item_data); + $this->assertTrue($this->item->save_value($itemData)); - $definition_data = [ + $definitionData = [ 'definition_name' => 'Color', 'definition_type' => TEXT, 'definition_flags' => 0, 'deleted' => 0 ]; - $definition_id = $this->attribute->saveDefinition($definition_data); - $attribute_value = 'Red'; - $attribute_id = $this->attribute->saveAttributeValue( - $attribute_value, - $definition_id, - $item_id, + $this->assertTrue($this->attribute->saveDefinition($definitionData)); + $this->assertNotEmpty($definitionData['definition_id']); + $definitionId = $definitionData['definition_id']; + + $this->assertNotNull($definitionId); + + + $attributeValue = 'Red'; + $attributeId = $this->attribute->saveAttributeValue( + $attributeValue, + $definitionId, + $itemData['item_id'], false, TEXT ); - $this->assertNotFalse($attribute_id); + $this->assertNotFalse($attributeId); - $saved_value = $this->attribute->getAttributeValue($item_id, $definition_id); - $this->assertEquals('Red', $saved_value->attribute_value); + $savedValue = $this->attribute->getAttributeValue($itemData['item_id'], $definitionId); + $this->assertEquals('Red', $savedValue->attribute_value); } public function testImportItemWithAttributeDropdown(): void { - $item_data = [ + $itemData = [ 'item_id' => null, + 'item_number' => '1234567890', 'name' => 'Item With Dropdown', 'category' => 'Test', 'cost_price' => 10.00, @@ -484,45 +553,49 @@ class ItemsCsvImportTest extends CIUnitTestCase 'deleted' => 0 ]; - $item_id = $this->item->save_value($item_data); + $this->assertTrue($this->item->save_value($itemData)); - $definition_data = [ + // Mock Attribute DROPDOWN + $definitionData = [ 'definition_name' => 'Size', 'definition_type' => DROPDOWN, 'definition_flags' => 0, 'deleted' => 0 ]; - $definition_id = $this->attribute->saveDefinition($definition_data); + $this->assertTrue($this->attribute->saveDefinition($definitionData)); + $definitionId = $definitionData['definition_id']; - $dropdown_values = ['Small', 'Medium', 'Large']; - foreach ($dropdown_values as $i => $value) { - $this->db->table('attribute_values')->insert([ - 'attribute_value' => $value, - 'definition_id' => $definition_id, - 'definition_type' => DROPDOWN, - 'attribute_group' => $i, - 'deleted' => 0 - ]); + // Mock Attribute DROPDOWN Values + $dropdownValues = ['Small', 'Medium', 'Large']; + foreach ($dropdownValues as $value) { + $this->attribute->saveAttributeValue( + $value, + $definitionId, + false, + false, + DROPDOWN + ); } - $attribute_value = 'Medium'; - $attribute_id = $this->attribute->saveAttributeValue( - $attribute_value, - $definition_id, - $item_id, + // Save dropdown attribute value for item + $attributeValue = 'Medium'; + $attributeId = $this->attribute->saveAttributeValue( + $attributeValue, + $definitionId, + $itemData['item_id'], false, DROPDOWN ); - $this->assertNotFalse($attribute_id); + $this->assertNotFalse($attributeId); - $saved_value = $this->attribute->getAttributeValue($item_id, $definition_id); - $this->assertEquals('Medium', $saved_value->attribute_value); + $savedValue = $this->attribute->getAttributeValue($itemData['item_id'], $definitionId); + $this->assertEquals('Medium', $savedValue->attribute_value); } public function testImportItemQuantityZero(): void { - $item_data = [ + $itemData = [ 'item_id' => null, 'name' => 'Item Zero Quantity', 'category' => 'Test', @@ -531,26 +604,26 @@ class ItemsCsvImportTest extends CIUnitTestCase 'deleted' => 0 ]; - $item_id = $this->item->save_value($item_data); + $this->assertTrue($this->item->save_value($itemData)); - $location_id = 1; + $locationId = 1; - $item_quantity_data = [ - 'item_id' => $item_id, - 'location_id' => $location_id, + $itemQuantityData = [ + 'item_id' => $itemData['item_id'], + 'location_id' => $locationId, 'quantity' => 0 ]; - $result = $this->item_quantity->save_value($item_quantity_data, $item_id, $location_id); + $result = $this->item_quantity->save_value($itemQuantityData, $itemData['item_id'], $locationId); $this->assertTrue($result); - $saved_quantity = $this->item_quantity->get_item_quantity($item_id, $location_id); - $this->assertEquals(0, (int)$saved_quantity->quantity); + $savedQuantity = $this->item_quantity->get_item_quantity($itemData['item_id'], $locationId); + $this->assertEquals(0, (int)$savedQuantity->quantity); } public function testImportItemWithNegativeReorderLevel(): void { - $item_data = [ + $itemData = [ 'item_id' => null, 'name' => 'Item Negative Reorder', 'category' => 'Test', @@ -560,36 +633,66 @@ class ItemsCsvImportTest extends CIUnitTestCase 'deleted' => 0 ]; - $item_id = $this->item->save_value($item_data); + $this->assertTrue($this->item->save_value($itemData)); - $saved_item = $this->item->get_info($item_id); - $this->assertEquals(-1, (int)$saved_item->reorder_level); + $savedItem = $this->item->get_info($itemData['item_id']); + $this->assertEquals(-1, (int)$savedItem->reorder_level); } - public function testImportItemWithHighPrecisionPrices(): void + public function testImportItemPriceRoundingBoundaries(): void { - $item_data = [ - 'item_id' => null, - 'name' => 'High Precision Item', - 'category' => 'Test', - 'cost_price' => 10.123456, - 'unit_price' => 25.876543, - 'deleted' => 0 + $cases = [ + [ + 'cost_price' => 10.004, + 'unit_price' => 25.004, + 'expected_cost_price' => '10.00', + 'expected_unit_price' => '25.00', + ], + [ + 'cost_price' => 10.005, + 'unit_price' => 25.005, + 'expected_cost_price' => '10.01', + 'expected_unit_price' => '25.01', + ], + [ + 'cost_price' => 10.006, + 'unit_price' => 25.006, + 'expected_cost_price' => '10.01', + 'expected_unit_price' => '25.01', + ], ]; - $item_id = $this->item->save_value($item_data); + foreach ($cases as $case) { + $itemData = [ + 'item_id' => null, + 'name' => 'Rounding Boundary Item ' . $case['cost_price'], + 'category' => 'Test', + 'cost_price' => $case['cost_price'], + 'unit_price' => $case['unit_price'], + 'deleted' => 0 + ]; - $saved_item = $this->item->get_info($item_id); - $cost_diff = abs(10.123456 - (float)$saved_item->cost_price); - $price_diff = abs(25.876543 - (float)$saved_item->unit_price); + $this->assertTrue($this->item->save_value($itemData)); - $this->assertLessThan(0.001, $cost_diff, 'Cost price should maintain precision'); - $this->assertLessThan(0.001, $price_diff, 'Unit price should maintain precision'); + $savedItem = $this->item->get_info($itemData['item_id']); + + $this->assertSame( + $case['expected_cost_price'], + number_format((float) $savedItem->cost_price, 2, '.', ''), + 'Cost price should be rounded correctly at the 3rd decimal boundary' + ); + + $this->assertSame( + $case['expected_unit_price'], + number_format((float) $savedItem->unit_price, 2, '.', ''), + 'Unit price should be rounded correctly at the 3rd decimal boundary' + ); + } } public function testImportItemWithHsnCode(): void { - $item_data = [ + $itemData = [ 'item_id' => null, 'name' => 'Item With HSN', 'category' => 'Test', @@ -599,15 +702,15 @@ class ItemsCsvImportTest extends CIUnitTestCase 'deleted' => 0 ]; - $item_id = $this->item->save_value($item_data); + $this->assertTrue($this->item->save_value($itemData)); - $saved_item = $this->item->get_info($item_id); - $this->assertEquals('8471', $saved_item->hsn_code); + $savedItem = $this->item->get_info($itemData['item_id']); + $this->assertEquals('8471', $savedItem->hsn_code); } public function testImportItemQuantityMultipleLocations(): void { - $item_data = [ + $itemData = [ 'item_id' => null, 'name' => 'Item Multi Location', 'category' => 'Test', @@ -616,32 +719,53 @@ class ItemsCsvImportTest extends CIUnitTestCase 'deleted' => 0 ]; - $item_id = $this->item->save_value($item_data); + $this->assertTrue($this->item->save_value($itemData)); - $quantities = [ - ['location_id' => 1, 'quantity' => 100], - ['location_id' => 2, 'quantity' => 50], - ['location_id' => 3, 'quantity' => 25] + $locations = [ + 'Warehouse' => 100, + 'Store' => 50, + 'Backroom' => 25, ]; - foreach ($quantities as $q) { + $locationIds = []; + + foreach ($locations as $locationName => $quantity) { + $locationData = [ + 'location_name' => $locationName, + 'deleted' => 0 + ]; + + $this->assertTrue($this->stock_location->save_value($locationData, NEW_ENTRY)); + $this->assertNotEmpty($locationData['location_id']); + + $locationIds[$locationName] = $locationData['location_id']; + $result = $this->item_quantity->save_value( - ['item_id' => $item_id, 'location_id' => $q['location_id'], 'quantity' => $q['quantity']], - $item_id, - $q['location_id'] + [ + 'item_id' => $itemData['item_id'], + 'location_id' => $locationData['location_id'], + 'quantity' => $quantity + ], + $itemData['item_id'], + $locationData['location_id'] ); + $this->assertTrue($result); } - foreach ($quantities as $q) { - $saved = $this->item_quantity->get_item_quantity($item_id, $q['location_id']); - $this->assertEquals($q['quantity'], (int)$saved->quantity, "Quantity at location {$q['location_id']} should match"); - } + $warehouseQuantity = $this->item_quantity->get_item_quantity($itemData['item_id'], $locationIds['Warehouse']); + $this->assertEquals(100, (int) $warehouseQuantity->quantity); + + $storeQuantity = $this->item_quantity->get_item_quantity($itemData['item_id'], $locationIds['Store']); + $this->assertEquals(50, (int) $storeQuantity->quantity); + + $backroomQuantity = $this->item_quantity->get_item_quantity($itemData['item_id'], $locationIds['Backroom']); + $this->assertEquals(25, (int) $backroomQuantity->quantity); } public function testCsvImportQuantityValidationNumeric(): void { - $csv_data = [ + $csvData = [ 'Id' => '', 'Barcode' => 'VALID-ITEM', 'Item Name' => 'Valid Item', @@ -651,14 +775,14 @@ class ItemsCsvImportTest extends CIUnitTestCase 'location_Warehouse' => '100' ]; - $this->assertTrue(is_numeric($csv_data['location_Warehouse'])); - $this->assertTrue(is_numeric($csv_data['Cost Price'])); - $this->assertTrue(is_numeric($csv_data['Unit Price'])); + $this->assertTrue(is_numeric($csvData['location_Warehouse'])); + $this->assertTrue(is_numeric($csvData['Cost Price'])); + $this->assertTrue(is_numeric($csvData['Unit Price'])); } public function testCsvImportEmptyBarcodeAllowed(): void { - $item_data = [ + $itemData = [ 'item_id' => null, 'name' => 'Item Without Barcode', 'category' => 'Test', @@ -668,18 +792,18 @@ class ItemsCsvImportTest extends CIUnitTestCase 'deleted' => 0 ]; - $item_id = $this->item->save_value($item_data); + $this->assertTrue($this->item->save_value($itemData)); - $this->assertIsInt($item_id); - $this->assertGreaterThan(0, $item_id); + $this->assertIsInt($itemData['item_id']); + $this->assertGreaterThan(0, $itemData['item_id']); - $saved_item = $this->item->get_info($item_id); - $this->assertEquals('Item Without Barcode', $saved_item->name); + $savedItem = $this->item->get_info($itemData['item_id']); + $this->assertEquals('Item Without Barcode', $savedItem->name); } public function testCsvImportItemExistsCheck(): void { - $item_data = [ + $itemData = [ 'item_id' => null, 'name' => 'Existing Item', 'category' => 'Test', @@ -688,18 +812,18 @@ class ItemsCsvImportTest extends CIUnitTestCase 'deleted' => 0 ]; - $item_id = $this->item->save_value($item_data); + $this->assertTrue($this->item->save_value($itemData)); - $exists = $this->item->exists($item_id); + $exists = $this->item->exists($itemData['item_id']); $this->assertTrue($exists); - $not_exists = $this->item->exists(999999); - $this->assertFalse($not_exists); + $notExists = $this->item->exists(999999); + $this->assertFalse($notExists); } public function testFullCsvImportFlowSimulated(): void { - $csv_row = [ + $csvRow = [ 'Id' => '', 'Barcode' => 'FULL-TEST-001', 'Item Name' => 'Complete Test Item', @@ -719,77 +843,76 @@ class ItemsCsvImportTest extends CIUnitTestCase 'HSN' => '84713020' ]; - $item_data = [ - 'item_id' => (int)$csv_row['Id'] ?: null, - 'name' => $csv_row['Item Name'], - 'description' => $csv_row['Description'], - 'category' => $csv_row['Category'], - 'cost_price' => (float)$csv_row['Cost Price'], - 'unit_price' => (float)$csv_row['Unit Price'], - 'reorder_level' => (int)$csv_row['Reorder Level'], - 'item_number' => $csv_row['Barcode'] ?: null, - 'allow_alt_description' => empty($csv_row['Allow Alt Description']) ? '0' : '1', - 'is_serialized' => empty($csv_row['Item has Serial Number']) ? '0' : '1', - 'hsn_code' => $csv_row['HSN'], + $itemData = [ + 'item_id' => (int)$csvRow['Id'] ?: null, + 'name' => $csvRow['Item Name'], + 'description' => $csvRow['Description'], + 'category' => $csvRow['Category'], + 'cost_price' => (float)$csvRow['Cost Price'], + 'unit_price' => (float)$csvRow['Unit Price'], + 'reorder_level' => (int)$csvRow['Reorder Level'], + 'item_number' => $csvRow['Barcode'] ?: null, + 'allow_alt_description' => empty($csvRow['Allow Alt Description']) ? '0' : '1', + 'is_serialized' => empty($csvRow['Item has Serial Number']) ? '0' : '1', + 'hsn_code' => $csvRow['HSN'], 'deleted' => 0 ]; - $item_id = $this->item->save_value($item_data); + $this->assertTrue($this->item->save_value($itemData)); - $taxes_data = []; - if (is_numeric($csv_row['Tax 1 Percent']) && $csv_row['Tax 1 Name'] !== '') { - $taxes_data[] = ['name' => $csv_row['Tax 1 Name'], 'percent' => $csv_row['Tax 1 Percent']]; + $taxesData = []; + if (is_numeric($csvRow['Tax 1 Percent']) && $csvRow['Tax 1 Name'] !== '') { + $taxesData[] = ['name' => $csvRow['Tax 1 Name'], 'percent' => $csvRow['Tax 1 Percent']]; } - if (is_numeric($csv_row['Tax 2 Percent']) && $csv_row['Tax 2 Name'] !== '') { - $taxes_data[] = ['name' => $csv_row['Tax 2 Name'], 'percent' => $csv_row['Tax 2 Percent']]; + if (is_numeric($csvRow['Tax 2 Percent']) && $csvRow['Tax 2 Name'] !== '') { + $taxesData[] = ['name' => $csvRow['Tax 2 Name'], 'percent' => $csvRow['Tax 2 Percent']]; } - if (!empty($taxes_data)) { - $this->item_taxes->save_value($taxes_data, $item_id); + if (!empty($taxesData)) { + $this->item_taxes->save_value($taxesData, $itemData['item_id']); } - $location_id = 1; + $locationId = 1; $quantity = 75; - $quantity_data = [ - 'item_id' => $item_id, - 'location_id' => $location_id, + $quantityData = [ + 'item_id' => $itemData['item_id'], + 'location_id' => $locationId, 'quantity' => $quantity ]; - $this->item_quantity->save_value($quantity_data, $item_id, $location_id); + $this->item_quantity->save_value($quantityData, $itemData['item_id'], $locationId); - $inventory_data = [ + $inventoryData = [ 'trans_inventory' => $quantity, - 'trans_items' => $item_id, - 'trans_location' => $location_id, + 'trans_items' => $itemData['item_id'], + 'trans_location' => $locationId, 'trans_comment' => 'CSV import quantity', 'trans_user' => 1 ]; - $this->inventory->insert($inventory_data); + $this->inventory->insert($inventoryData); - $saved_item = $this->item->get_info($item_id); - $this->assertEquals('Complete Test Item', $saved_item->name); - $this->assertEquals('Electronics', $saved_item->category); - $this->assertEquals(50.00, (float)$saved_item->cost_price); - $this->assertEquals(100.00, (float)$saved_item->unit_price); - $this->assertEquals('84713020', $saved_item->hsn_code); + $savedItem = $this->item->get_info($itemData['item_id']); + $this->assertEquals('Complete Test Item', $savedItem->name); + $this->assertEquals('Electronics', $savedItem->category); + $this->assertEquals(50.00, (float)$savedItem->cost_price); + $this->assertEquals(100.00, (float)$savedItem->unit_price); + $this->assertEquals('84713020', $savedItem->hsn_code); - $saved_quantity = $this->item_quantity->get_item_quantity($item_id, $location_id); - $this->assertEquals($quantity, (int)$saved_quantity->quantity); + $savedQuantity = $this->item_quantity->get_item_quantity($itemData['item_id'], $locationId); + $this->assertEquals($quantity, (int)$savedQuantity->quantity); - $saved_taxes = $this->item_taxes->get_info($item_id); - $this->assertCount(1, $saved_taxes); - $this->assertEquals('VAT', $saved_taxes[0]['name']); - $this->assertEquals(20, (float)$saved_taxes[0]['percent']); + $savedTaxes = $this->item_taxes->get_info($itemData['item_id']); + $this->assertCount(1, $savedTaxes); + $this->assertEquals('VAT', $savedTaxes[0]['name']); + $this->assertEquals(20, (float)$savedTaxes[0]['percent']); - $inventory_records = $this->inventory->get_inventory_data_for_item($item_id, $location_id); - $this->assertGreaterThanOrEqual(1, $inventory_records->getNumRows()); + $inventoryRecords = $this->inventory->get_inventory_data_for_item($itemData['item_id'], $locationId); + $this->assertGreaterThanOrEqual(1, $inventoryRecords->getNumRows()); } public function testImportCsvInvalidStockLocationColumn(): void { - $csv_headers = ['Id', 'Item Name', 'Category', 'Cost Price', 'Unit Price', 'location_NonExistentLocation']; - $csv_row = [ + $csvRow = [ 'Id' => '', 'Item Name' => 'Test Item Invalid Location', 'Category' => 'Test', @@ -798,29 +921,29 @@ class ItemsCsvImportTest extends CIUnitTestCase 'location_NonExistentLocation' => '100' ]; - $allowed_locations = [1 => 'Warehouse']; + $allowedLocations = [1 => 'Warehouse']; - $location_columns_in_csv = []; - foreach (array_keys($csv_row) as $key) { + $locationColumnsInCsv = []; + foreach (array_keys($csvRow) as $key) { if (str_starts_with($key, 'location_')) { - $location_columns_in_csv[$key] = substr($key, 9); + $locationColumnsInCsv[$key] = substr($key, 9); } } - $invalid_locations = []; - foreach ($location_columns_in_csv as $column => $location_name) { - if (!in_array($location_name, $allowed_locations)) { - $invalid_locations[] = $location_name; + $invalidLocations = []; + foreach ($locationColumnsInCsv as $column => $locationName) { + if (!in_array($locationName, $allowedLocations)) { + $invalidLocations[] = $locationName; } } - $this->assertNotEmpty($invalid_locations, 'Should detect invalid location in CSV'); - $this->assertContains('NonExistentLocation', $invalid_locations); + $this->assertNotEmpty($invalidLocations, 'Should detect invalid location in CSV'); + $this->assertContains('NonExistentLocation', $invalidLocations); } public function testImportCsvValidStockLocationColumn(): void { - $csv_row = [ + $csvRow = [ 'Id' => '', 'Item Name' => 'Test Item Valid Location', 'Category' => 'Test', @@ -829,28 +952,28 @@ class ItemsCsvImportTest extends CIUnitTestCase 'location_Warehouse' => '100' ]; - $allowed_locations = [1 => 'Warehouse']; + $allowedLocations = [1 => 'Warehouse']; - $location_columns_in_csv = []; - foreach (array_keys($csv_row) as $key) { + $locationColumnsInCsv = []; + foreach (array_keys($csvRow) as $key) { if (str_starts_with($key, 'location_')) { - $location_columns_in_csv[$key] = substr($key, 9); + $locationColumnsInCsv[$key] = substr($key, 9); } } - $invalid_locations = []; - foreach ($location_columns_in_csv as $column => $location_name) { - if (!in_array($location_name, $allowed_locations)) { - $invalid_locations[] = $location_name; + $invalidLocations = []; + foreach ($locationColumnsInCsv as $column => $locationName) { + if (!in_array($locationName, $allowedLocations)) { + $invalidLocations[] = $locationName; } } - $this->assertEmpty($invalid_locations, 'Should have no invalid locations'); + $this->assertEmpty($invalidLocations, 'Should have no invalid locations'); } public function testImportCsvMixedValidAndInvalidLocations(): void { - $csv_row = [ + $csvRow = [ 'Id' => '', 'Item Name' => 'Test Item Mixed Locations', 'Category' => 'Test', @@ -860,47 +983,47 @@ class ItemsCsvImportTest extends CIUnitTestCase 'location_InvalidLocation' => '50' ]; - $allowed_locations = [1 => 'Warehouse', 2 => 'Store']; + $allowedLocations = [1 => 'Warehouse', 2 => 'Store']; - $location_columns_in_csv = []; - foreach (array_keys($csv_row) as $key) { + $locationColumnsInCsv = []; + foreach (array_keys($csvRow) as $key) { if (str_starts_with($key, 'location_')) { - $location_columns_in_csv[$key] = substr($key, 9); + $locationColumnsInCsv[$key] = substr($key, 9); } } - $invalid_locations = []; - foreach ($location_columns_in_csv as $column => $location_name) { - if (!in_array($location_name, $allowed_locations)) { - $invalid_locations[] = $location_name; + $invalidLocations = []; + foreach ($locationColumnsInCsv as $column => $locationName) { + if (!in_array($locationName, $allowedLocations)) { + $invalidLocations[] = $locationName; } } - $this->assertCount(1, $invalid_locations, 'Should have exactly one invalid location'); - $this->assertContains('InvalidLocation', $invalid_locations); + $this->assertCount(1, $invalidLocations, 'Should have exactly one invalid location'); + $this->assertContains('InvalidLocation', $invalidLocations); } public function testValidateCsvStockLocations(): void { - $csv_content = "Id,\"Item Name\",Category,\"Cost Price\",\"Unit Price\",\"location_Warehouse\",\"location_FakeLocation\"\n"; - $csv_content .= ",Test Item,Test,10.00,20.00,100,50\n"; + $csvContent = "Id,\"Item Name\",Category,\"Cost Price\",\"Unit Price\",\"location_Warehouse\",\"location_FakeLocation\"\n"; + $csvContent .= ",Test Item,Test,10.00,20.00,100,50\n"; - $temp_file = tempnam(sys_get_temp_dir(), 'csv_location_test_'); - file_put_contents($temp_file, $csv_content); + $tempFile = tempnam(sys_get_temp_dir(), 'csv_location_test_'); + file_put_contents($tempFile, $csvContent); - $rows = get_csv_file($temp_file); + $rows = get_csv_file($tempFile); $this->assertCount(1, $rows); $row = $rows[0]; $this->assertArrayHasKey('location_Warehouse', $row); $this->assertArrayHasKey('location_FakeLocation', $row); - unlink($temp_file); + unlink($tempFile); } public function testImportItemQuantityOnlyForValidLocations(): void { - $item_data = [ + $itemData = [ 'item_id' => null, 'name' => 'Item Location Test', 'category' => 'Test', @@ -909,35 +1032,45 @@ class ItemsCsvImportTest extends CIUnitTestCase 'deleted' => 0 ]; - $item_id = $this->item->save_value($item_data); + $this->assertTrue($this->item->save_value($itemData)); - $allowed_locations = [1 => 'Warehouse', 2 => 'Store']; + $uniqueId = uniqid(); + $locations = ['Warehouse' . $uniqueId, 'Store' . $uniqueId]; - $csv_row_simulated = [ - 'location_Warehouse' => '100', - 'location_Store' => '50', + $allowedLocations = []; + foreach ($locations as $locationName) { + $currentLocation = ['location_name' => $locationName, 'deleted' => 0]; + $this->assertTrue($this->stock_location->save_value($currentLocation, NEW_ENTRY)); + $allowedLocations[$currentLocation['location_id']] = $locationName; + } + + $csvRowSimulated = [ + 'location_Warehouse' . $uniqueId => '100', + 'location_Store' . $uniqueId => '50', 'location_NonExistent' => '25' ]; - foreach ($allowed_locations as $location_id => $location_name) { - $column_name = "location_$location_name"; - if (isset($csv_row_simulated[$column_name]) || $csv_row_simulated[$column_name] === '0') { - $quantity_data = [ - 'item_id' => $item_id, - 'location_id' => $location_id, - 'quantity' => (int)$csv_row_simulated[$column_name] + foreach ($allowedLocations as $locationId => $locationName) { + $columnName = "location_$locationName"; + if (isset($csvRowSimulated[$columnName]) || $csvRowSimulated[$columnName] === '0') { + $quantityData = [ + 'item_id' => $itemData['item_id'], + 'location_id' => $locationId, + 'quantity' => (int)$csvRowSimulated[$columnName] ]; - $this->item_quantity->save_value($quantity_data, $item_id, $location_id); + $this->item_quantity->save_value($quantityData, $itemData['item_id'], $locationId); } } - $warehouse_qty = $this->item_quantity->get_item_quantity($item_id, 1); - $this->assertEquals(100, (int)$warehouse_qty->quantity); + $warehouseId = array_search('Warehouse' . $uniqueId, $allowedLocations, true); + $warehouseQuantity = $this->item_quantity->get_item_quantity($itemData['item_id'], $warehouseId); + $this->assertEquals(100, (int)$warehouseQuantity->quantity); - $store_qty = $this->item_quantity->get_item_quantity($item_id, 2); - $this->assertEquals(50, (int)$store_qty->quantity); + $storeId = array_search('Store'. $uniqueId, $allowedLocations, true); + $storeQuantity = $this->item_quantity->get_item_quantity($itemData['item_id'], $storeId); + $this->assertEquals(50, (int)$storeQuantity->quantity); - $result = $this->item_quantity->exists($item_id, 999); + $result = $this->item_quantity->exists($itemData['item_id'], 999); $this->assertFalse($result, 'Should not have quantity for non-existent location'); } @@ -951,31 +1084,31 @@ class ItemsCsvImportTest extends CIUnitTestCase 'attribute_Color' => 'Red' ]; - $location_columns = []; + $locationColumns = []; foreach (array_keys($row) as $key) { if (str_starts_with($key, 'location_')) { - $location_columns[$key] = substr($key, 9); + $locationColumns[$key] = substr($key, 9); } } - $this->assertCount(2, $location_columns); - $this->assertArrayHasKey('location_Warehouse', $location_columns); - $this->assertArrayHasKey('location_Store', $location_columns); - $this->assertEquals('Warehouse', $location_columns['location_Warehouse']); - $this->assertEquals('Store', $location_columns['location_Store']); + $this->assertCount(2, $locationColumns); + $this->assertArrayHasKey('location_Warehouse', $locationColumns); + $this->assertArrayHasKey('location_Store', $locationColumns); + $this->assertEquals('Warehouse', $locationColumns['location_Warehouse']); + $this->assertEquals('Store', $locationColumns['location_Store']); } public function testValidateLocationNamesCaseSensitivity(): void { - $allowed_locations = [1 => 'Warehouse', 2 => 'Store']; + $allowedLocations = [1 => 'Warehouse', 2 => 'Store']; - $csv_location_name = 'warehouse'; + $csvLocationName = 'warehouse'; - $is_valid = in_array($csv_location_name, $allowed_locations); - $this->assertFalse($is_valid, 'Location names should be case-sensitive'); + $isValid = in_array($csvLocationName, $allowedLocations); + $this->assertFalse($isValid, 'Location names should be case-sensitive'); - $csv_location_name = 'Warehouse'; - $is_valid = in_array($csv_location_name, $allowed_locations); - $this->assertTrue($is_valid, 'Valid location name should pass validation'); + $csvLocationName = 'Warehouse'; + $isValid = in_array($csvLocationName, $allowedLocations); + $this->assertTrue($isValid, 'Valid location name should pass validation'); } -} \ No newline at end of file +}