diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..c637ad8 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,24 @@ +{ + "name": "RazorPagesMovie Dev Container", + "image": "mcr.microsoft.com/devcontainers/dotnet:6.0", + "forwardPorts": [5000, 5001], + "features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { + "version": "latest", + "autoForwardPorts": true, + "moby": true + } + }, + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind", + "workspaceFolder": "/workspace", + "customizations": { + "vscode": { + "settings": { + "terminal.integrated.shell.linux": "/bin/bash" + }, + "extensions": [ + "GitHub.copilot" + ] + } + } +} diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 4226210..cd48c59 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -21,20 +21,22 @@ on: required: false type: string default: 'false' + env: RESOURCE_GROUP: "tsvi-rg" CONTAINER_REGISTRY: "ghcr.io/octodemo/dotnet-razor-pages-movie" jobs: - staging-end2end-tests: + setup-staging-environment: permissions: actions: read - id-token: write # This is required for requesting the JWT - contents: read # This is required for actions/checkout - issues: write + id-token: write + contents: read - runs-on: ubuntu-latest + outputs: + CONTAINER_APP_URL: ${{ steps.capture_outputs.outputs.CONTAINER_APP_URL }} + HASH_URL: ${{ steps.generate_url.outputs.HASH_URL }} environment: STAGE steps: - name: Checkout code @@ -66,7 +68,7 @@ jobs: - name: Capture Terraform Outputs id: capture_outputs run: | - echo "CONTAINER_APP_URL=$(terraform output -raw container_app_url)" >> $GITHUB_ENV + echo "CONTAINER_APP_URL=$(terraform output -raw container_app_url)" >> $GITHUB_OUTPUT working-directory: ./terraform/staging env: ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} @@ -74,20 +76,62 @@ jobs: ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} ARM_USE_OIDC: true - - name: Upload Teraaform State and files - uses: actions/upload-artifact@v4.4.3 - with: - name: terraform-state - path: ./terraform/staging/ + - name: Generate URL at commit hash to IaC staging files + id: generate_url + run: | + HASH_URL="https://github.com/${{ github.repository }}/tree/${{ github.sha }}/terraform/staging" + echo "HASH_URL=${HASH_URL}" >> $GITHUB_OUTPUT + + ui-tests: + needs: setup-staging-environment + permissions: + actions: read + contents: read + + strategy: + matrix: + browser: [chrome, firefox, edge, chromium] - - name: Generate Artifact URL - id: generate_artifact_url + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4.1.7 + + - name: Setup .NET + uses: actions/setup-dotnet@v4.0.1 + with: + dotnet-version: '6.0.x' + + - name: Run UI Automated Selenium Tests run: | - ARTIFACT_ID=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts | jq -r '.artifacts[] | select(.name=="terraform-state") | .id') - echo "ARTIFACT_ID=$ARTIFACT_ID" >> $GITHUB_ENV - echo "ARTIFACT_URL=https://github.com/octodemo/dotnet-razor-pages-movie/actions/runs/${{ github.run_id }}/artifacts/${ARTIFACT_ID}" >> $GITHUB_ENV + dotnet test RazorPagesMovie.UITests/RazorPagesMovie.UITests.csproj --logger "console;verbosity=detailed" + working-directory: tests + env: + BROWSER: ${{ matrix.browser }} + BASE_URL: "https://${{ needs.setup-staging-environment.outputs.CONTAINER_APP_URL }}" + + workflow-telemetry: + needs: [ui-tests] + runs-on: ubuntu-latest + + steps: + - name: Workflow Telemetry - Generate heat map and performance data + uses: catchpoint/workflow-telemetry-action@v2.0.0 + + create-qa-ticket: + needs: [ui-tests] + permissions: + actions: read + contents: read + issues: write + pull-requests: write + + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4.1.7 - # Open an issue and notify QA that the staging environment is ready for testing - name: Create Issue uses: actions/github-script@v7.0.1 with: @@ -106,8 +150,7 @@ jobs: - **Image Tag:** 🐳 [Docker Image](https://github.com/octodemo/dotnet-razor-pages-movie/pkgs/container/dotnet-razor-pages-movie) tag: \`${process.env.IMAGE_TAG}\` - **App Server URL:** 🔗 https://${process.env.CONTAINER_APP_URL} - **Terraform IaC files:** - - **Artifact ID:** \`${process.env.ARTIFACT_ID}\` - - **Artifact URL:** [📁 Download](${process.env.ARTIFACT_URL}) + - **Terraform Files at Commit Hash:** 🛠️ [Commit SHA](${process.env.HASH_URL}) `; github.rest.issues.create({ @@ -122,20 +165,20 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} IMAGE_TAG: ${{ inputs.image_tag }} CONTAINER_APP_URL: ${{ env.CONTAINER_APP_URL }} - ARTIFACT_ID: ${{ env.ARTIFACT_ID }} - + HASH_URL: ${{ env.HASH_URL }} production: permissions: actions: read - id-token: write # This is required for requesting the JWT - contents: write # This is required for actions/checkout and the create-release action + id-token: write + contents: write runs-on: ubuntu-latest environment: name: PROD url: https://tsvi-demo-movie.salmontree-a9d9695c.eastus.azurecontainerapps.io - needs: [staging-end2end-tests] + needs: [create-qa-ticket] + if: github.ref == 'refs/heads/main' steps: - name: Checkout code uses: actions/checkout@v4.1.7 @@ -162,20 +205,7 @@ jobs: ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} ARM_USE_OIDC: true - - - name: Upload Teraaform State and files - uses: actions/upload-artifact@v4.4.3 - with: - name: terraform-state-prod - path: ./terraform/production/ - - name: Generate Artifact URL - id: generate_artifact_url - run: | - ARTIFACT_ID=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts | jq -r '.artifacts[] | select(.name=="terraform-state-prod") | .id') - echo "ARTIFACT_ID=$ARTIFACT_ID" >> $GITHUB_ENV - echo "ARTIFACT_URL=https://github.com/octodemo/dotnet-razor-pages-movie/actions/runs/${{ github.run_id }}/artifacts/${ARTIFACT_ID}" >> $GITHUB_ENV - - name: Capture Terraform Output id: output run: echo "CONTAINER_APP_URL=$(terraform output -raw container_app_url)" >> $GITHUB_ENV @@ -193,8 +223,6 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} IMAGE_TAG: ${{ inputs.image_tag }} CONTAINER_APP_URL: ${{ env.CONTAINER_APP_URL }} - ARTIFACT_ID: ${{ env.ARTIFACT_ID }} - ARTIFACT_URL: ${{ env.ARTIFACT_URL }} with: tag: ${{ inputs.image_tag }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ce3204..cdc4150 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,8 +15,6 @@ on: description: 'Run the pipeline with debug deployment enabled' required: false default: 'false' - - # runnnig on push to main and develop branches push: branches: - main @@ -26,15 +24,16 @@ on: - '.devcontainer/**' - '.github/ISSUE_TEMPLATE/**' - '.github/workflows/housekeeping*.yml' - - # running on pull requests to main and develop branches - pull_request: + + pull_request_target: branches: - main - develop paths-ignore: - '**/README.md' - '.devcontainer/**' + - '.github/ISSUE_TEMPLATE/**' + - '.github/workflows/housekeeping*.yml' # defining global environment variables for all jobs env: @@ -48,8 +47,8 @@ env: jobs: build: - runs-on: ${{ matrix.os }} - name: Build ${{ matrix.os }} + runs-on: ${{ matrix.runner }} + name: Build (${{ matrix.language }}) permissions: actions: read contents: read @@ -59,20 +58,31 @@ jobs: strategy: matrix: - # os: [ubuntu-latest, windows-latest, macos-latest] - os: [ubuntu-latest] - language: [csharp] + include: + - language: csharp + build-mode: manual + runner: tsvi-linux8cores + - language: javascript-typescript + build-mode: none + runner: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4.2.2 + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + - name: Setup .NET uses: actions/setup-dotnet@v4.0.1 with: dotnet-version: '6.0.x' - name: Cache NuGet packages + if: matrix.build-mode == 'manual' uses: actions/cache@v4.0.2 with: path: ~/.nuget/packages @@ -80,24 +90,36 @@ jobs: restore-keys: | ${{ runner.os }}-nuget- + - name: Restore dependencies - run: dotnet restore RazorPagesMovie.csproj + if: matrix.language == 'csharp' && matrix.build-mode == 'manual' + run: dotnet restore RazorPagesMovie.sln - name: Build App project - run: dotnet build RazorPagesMovie.csproj --configuration Release --no-restore + if: matrix.language == 'csharp' && matrix.build-mode == 'manual' + run: dotnet build RazorPagesMovie.sln --configuration Release --no-restore - - name: Set runtime - id: set-runtime - run: echo "RUNTIME=${{ matrix.os == 'ubuntu-latest' && 'linux-x64' || matrix.os == 'windows-latest' && 'win-x64' || 'osx-x64' }}" >> $GITHUB_ENV + # - name: Set runtime + # if: matrix.language == 'csharp' + # id: set-runtime + # run: echo "RUNTIME=${{ matrix.os == 'ubuntu-latest' && 'linux-x64' || matrix.os == 'windows-latest' && 'win-x64' || 'osx-x64' }}" >> $GITHUB_ENV - name: Publish - run: dotnet publish RazorPagesMovie.csproj --configuration Release --output ./publish --self-contained --runtime ${{ env.RUNTIME }} + if: matrix.language == 'csharp' && matrix.build-mode == 'manual' + run: dotnet publish RazorPagesMovie.csproj --configuration Release --output publish --self-contained --runtime linux-x64 + working-directory: src - name: Upload published app + if: matrix.language == 'csharp' && matrix.build-mode == 'manual' uses: actions/upload-artifact@v4.4.3 with: name: razor-linux-arm64 - path: publish/ + path: src/publish/ + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" test: runs-on: ${{ matrix.os }} @@ -141,35 +163,41 @@ jobs: id: split-test uses: scruplelesswizard/split-tests@4f1ca766cb93923ca216e02f1aefed20944e313f with: - glob: RazorPagesMovie.Tests/**/*Tests.cs + glob: tests/RazorPagesMovie.Tests/**/*Tests.cs split-total: ${{ env.total-runners }} split-index: ${{ matrix.index }} line-count: true - name: Restore dependencies run: dotnet restore RazorPagesMovie.Tests/RazorPagesMovie.Tests.csproj + working-directory: tests - name: Convert Test File Path to Fully Qualified Name id: convert-path run: | test_suite="${{ steps.split-test.outputs.test-suite }}" - fully_qualified_name=$(echo $test_suite | sed 's/\//./g' | sed 's/.cs//g') + echo "test_suite=$test_suite" + fully_qualified_name=$(echo $test_suite | sed 's/\//./g' | sed 's/.cs//g' | sed 's/^tests\.//g' | xargs) echo "fully_qualified_name=$fully_qualified_name" >> $GITHUB_ENV - - - run: 'echo "This runner will execute the following tests: ${{ steps.split-test.outputs.test-suite }}."' + working-directory: tests + + - run: 'echo "This runner will execute the following tests: ${{ steps.split-test.outputs.test-suite }}"' + - run: 'echo "Fully qualified name: ${{ env.fully_qualified_name }}"' + - run: | dotnet test RazorPagesMovie.Tests/RazorPagesMovie.Tests.csproj \ --filter "FullyQualifiedName~${{ env.fully_qualified_name }}" \ --logger "console;verbosity=detailed" \ - --logger "trx;LogFileName=./testresults-${{ matrix.index }}-testresults-${{ matrix.os }}-${{ github.run_id }}-${{ github.run_attempt }}.trx" \ - --results-directory ./testresults + --logger "trx;LogFileName=testresults-${{ matrix.index }}-testresults-${{ matrix.os }}-${{ github.run_id }}-${{ github.run_attempt }}.trx" \ + --results-directory testresults + working-directory: tests - name: Upload test results if: always() uses: actions/upload-artifact@v4.3.6 with: name: testresults-${{ github.run_id }}-split-${{ matrix.index }} - path: ./testresults + path: tests/testresults/ if-no-files-found: warn compression-level: 6 @@ -210,8 +238,7 @@ jobs: outputs: image_tag: ${{ github.run_number }} # output the image tag to be used in the build-and-publish-docker-image job needs: [build, test] # depend on the build job to get the published app artifact - if: github.ref == 'refs/heads/main' # run this job only when the branch is main branch and not on pull requests or other branches - https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#github-context - # permissions for write acces to the packages and id-token and push access to the repository to create the container registry token + if: github.event_name == 'push' || (github.event_name == 'pull_request_target' && github.base_ref == 'main' && github.head_ref == 'develop') permissions: packages: write id-token: write diff --git a/.gitignore b/.gitignore index f79b823..5e1eea2 100644 --- a/.gitignore +++ b/.gitignore @@ -41,8 +41,8 @@ packages/ # ASP.NET Scaffolding ScaffoldingReadMe.txt -# Entity Framework Migrations -Migrations/ +# Entity Framework Migrations Migrations/snapshot files (any files that container the word "Snapshot" in the name) +**/Migrations/*Snapshot*.cs # Exclude appsettings files appsettings*.json @@ -63,4 +63,8 @@ publish/ *.tfstate.* .terraform/ .terraform.lock.hcl -*tfplan \ No newline at end of file +*tfplan + +# Exclude .err and .out +*.err +*.out \ No newline at end of file diff --git a/Data/RazorPagesMovieContext.cs b/Data/RazorPagesMovieContext.cs deleted file mode 100644 index 86569a5..0000000 --- a/Data/RazorPagesMovieContext.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using RazorPagesMovie.Models; - -namespace RazorPagesMovie.Data -{ - public class RazorPagesMovieContext : DbContext - { - public RazorPagesMovieContext (DbContextOptions options) - : base(options) - { - } - - public DbSet Movie { get; set; } = default!; - } -} diff --git a/Migrations/20240613200303_InitialCreate.Designer.cs b/Migrations/20240613200303_InitialCreate.Designer.cs deleted file mode 100644 index d47abef..0000000 --- a/Migrations/20240613200303_InitialCreate.Designer.cs +++ /dev/null @@ -1,55 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using RazorPagesMovie.Data; - -#nullable disable - -namespace RazorPagesMovie.Migrations -{ - [DbContext(typeof(RazorPagesMovieContext))] - [Migration("20240613200303_InitialCreate")] - partial class InitialCreate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.0-preview.3.24172.4") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("RazorPagesMovie.Models.Movie", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Genre") - .HasColumnType("nvarchar(max)"); - - b.Property("Price") - .HasColumnType("decimal(18,2)"); - - b.Property("ReleaseDate") - .HasColumnType("datetime2"); - - b.Property("Title") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.ToTable("Movie"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Migrations/20240613200303_InitialCreate.cs b/Migrations/20240613200303_InitialCreate.cs deleted file mode 100644 index c343c42..0000000 --- a/Migrations/20240613200303_InitialCreate.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace RazorPagesMovie.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Movie", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Title = table.Column(type: "nvarchar(max)", nullable: true), - ReleaseDate = table.Column(type: "datetime2", nullable: false), - Genre = table.Column(type: "nvarchar(max)", nullable: true), - Price = table.Column(type: "decimal(18,2)", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Movie", x => x.Id); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Movie"); - } - } -} diff --git a/Migrations/20240623214222_Rating.Designer.cs b/Migrations/20240623214222_Rating.Designer.cs deleted file mode 100644 index 756a19d..0000000 --- a/Migrations/20240623214222_Rating.Designer.cs +++ /dev/null @@ -1,61 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using RazorPagesMovie.Data; - -#nullable disable - -namespace RazorPagesMovie.Migrations -{ - [DbContext(typeof(RazorPagesMovieContext))] - [Migration("20240623214222_Rating")] - partial class Rating - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.0-preview.3.24172.4") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("RazorPagesMovie.Models.Movie", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Genre") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Price") - .HasColumnType("decimal(18, 2)"); - - b.Property("Rating") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReleaseDate") - .HasColumnType("datetime2"); - - b.Property("Title") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.ToTable("Movie"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Migrations/20240623214222_Rating.cs b/Migrations/20240623214222_Rating.cs deleted file mode 100644 index 226eb86..0000000 --- a/Migrations/20240623214222_Rating.cs +++ /dev/null @@ -1,65 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace RazorPagesMovie.Migrations -{ - /// - public partial class Rating : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn( - name: "Title", - table: "Movie", - type: "nvarchar(max)", - nullable: false, - defaultValue: "", - oldClrType: typeof(string), - oldType: "nvarchar(max)", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "Genre", - table: "Movie", - type: "nvarchar(max)", - nullable: false, - defaultValue: "", - oldClrType: typeof(string), - oldType: "nvarchar(max)", - oldNullable: true); - - migrationBuilder.AddColumn( - name: "Rating", - table: "Movie", - type: "nvarchar(max)", - nullable: false, - defaultValue: ""); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "Rating", - table: "Movie"); - - migrationBuilder.AlterColumn( - name: "Title", - table: "Movie", - type: "nvarchar(max)", - nullable: true, - oldClrType: typeof(string), - oldType: "nvarchar(max)"); - - migrationBuilder.AlterColumn( - name: "Genre", - table: "Movie", - type: "nvarchar(max)", - nullable: true, - oldClrType: typeof(string), - oldType: "nvarchar(max)"); - } - } -} diff --git a/Migrations/20240623215214_RatingTwo.Designer.cs b/Migrations/20240623215214_RatingTwo.Designer.cs deleted file mode 100644 index eb978b0..0000000 --- a/Migrations/20240623215214_RatingTwo.Designer.cs +++ /dev/null @@ -1,61 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using RazorPagesMovie.Data; - -#nullable disable - -namespace RazorPagesMovie.Migrations -{ - [DbContext(typeof(RazorPagesMovieContext))] - [Migration("20240623215214_RatingTwo")] - partial class RatingTwo - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.0-preview.3.24172.4") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("RazorPagesMovie.Models.Movie", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Genre") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Price") - .HasColumnType("decimal(18, 2)"); - - b.Property("Rating") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReleaseDate") - .HasColumnType("datetime2"); - - b.Property("Title") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.ToTable("Movie"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Migrations/20240623215214_RatingTwo.cs b/Migrations/20240623215214_RatingTwo.cs deleted file mode 100644 index 021c89c..0000000 --- a/Migrations/20240623215214_RatingTwo.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace RazorPagesMovie.Migrations -{ - /// - public partial class RatingTwo : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - - } - } -} diff --git a/Migrations/RazorPagesMovieContextModelSnapshot.cs b/Migrations/RazorPagesMovieContextModelSnapshot.cs deleted file mode 100644 index 119d4da..0000000 --- a/Migrations/RazorPagesMovieContextModelSnapshot.cs +++ /dev/null @@ -1,61 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using RazorPagesMovie.Data; - -#nullable disable - -namespace RazorPagesMovie.Migrations -{ - [DbContext(typeof(RazorPagesMovieContext))] - partial class RazorPagesMovieContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.0-preview.3.24172.4") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("RazorPagesMovie.Models.Movie", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Genre") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("nvarchar(30)"); - - b.Property("Price") - .HasColumnType("decimal(18, 2)"); - - b.Property("Rating") - .IsRequired() - .HasMaxLength(5) - .HasColumnType("nvarchar(5)"); - - b.Property("ReleaseDate") - .HasColumnType("datetime2"); - - b.Property("Title") - .IsRequired() - .HasMaxLength(60) - .HasColumnType("nvarchar(60)"); - - b.HasKey("Id"); - - b.ToTable("Movie"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Models/Movie.cs b/Models/Movie.cs deleted file mode 100644 index 60f30af..0000000 --- a/Models/Movie.cs +++ /dev/null @@ -1,30 +0,0 @@ -#define COMBINED -#if COMBINED -// -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace RazorPagesMovie.Models; - -public class Movie -{ - public int Id { get; set; } - - [StringLength(60, MinimumLength = 3)] - public string Title { get; set; } = string.Empty; - - [Display(Name = "Release Date"), DataType(DataType.Date)] - public DateTime ReleaseDate { get; set; } - - [RegularExpression(@"^[A-Z]+[a-zA-Z\s]*$"), Required, StringLength(30)] - public string Genre { get; set; } = string.Empty; - - [Range(1, 100), DataType(DataType.Currency)] - [Column(TypeName = "decimal(18, 2)")] - public decimal Price { get; set; } - - [RegularExpression(@"^[A-Z]+[a-zA-Z0-9""'\s-]*$"), StringLength(5)] - public string Rating { get; set; } = string.Empty; -} -// -#endif diff --git a/Pages/Movies/Edit.cshtml b/Pages/Movies/Edit.cshtml deleted file mode 100644 index 35e6de8..0000000 --- a/Pages/Movies/Edit.cshtml +++ /dev/null @@ -1,55 +0,0 @@ -@page -@model RazorPagesMovie.Pages.Movies.EditModel - -@{ - ViewData["Title"] = "Edit"; -} - -

Edit

- -

Movie

-
-
-
-
-
- -
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- -
-
-
-
- - - -@section Scripts { - @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} -} diff --git a/Program.cs b/Program.cs deleted file mode 100644 index 1c2f0a4..0000000 --- a/Program.cs +++ /dev/null @@ -1,72 +0,0 @@ -#define DEFAULT // SQL server is default, SQL_Lite is other -#if DEFAULT -using Microsoft.EntityFrameworkCore; -using RazorPagesMovie.Data; -using RazorPagesMovie.Models; - -var builder = WebApplication.CreateBuilder(args); - -builder.Services.AddRazorPages(); -builder.Services.AddDbContext(options => - options.UseSqlServer(builder.Configuration.GetConnectionString("RazorPagesMovieContext") ?? throw new InvalidOperationException("Connection string 'RazorPagesMovieContext' not found."))); - -var app = builder.Build(); - -using (var scope = app.Services.CreateScope()) -{ - var services = scope.ServiceProvider; - var context = services.GetRequiredService(); - context.Database.Migrate(); // Apply Database schema migrations - SeedData.Initialize(services); // Seed data -} - -if (!app.Environment.IsDevelopment()) -{ - app.UseExceptionHandler("/Error"); - app.UseHsts(); -} - -app.UseHttpsRedirection(); -app.UseStaticFiles(); -app.UseRouting(); -app.UseAuthorization(); - -app.MapRazorPages(); - -app.Run(); -#elif SQL_Lite -using Microsoft.EntityFrameworkCore; -using RazorPagesMovie.Data; -using RazorPagesMovie.Models; - -var builder = WebApplication.CreateBuilder(args); - -builder.Services.AddRazorPages(); -builder.Services.AddDbContext(options => - options.UseSqlite(builder.Configuration.GetConnectionString("RazorPagesMovieContext") ?? throw new InvalidOperationException("Connection string 'RazorPagesMovieContext' not found."))); - -var app = builder.Build(); - -using (var scope = app.Services.CreateScope()) -{ - var services = scope.ServiceProvider; - var context = services.GetRequiredService(); - context.Database.Migrate(); - SeedData.Initialize(services); -} - -if (!app.Environment.IsDevelopment()) -{ - app.UseExceptionHandler("/Error"); - app.UseHsts(); -} - -app.UseHttpsRedirection(); -app.UseStaticFiles(); // Add this line -app.UseRouting(); -app.UseAuthorization(); - -app.MapRazorPages(); - -app.Run(); -#endif \ No newline at end of file diff --git a/RazorPagesMovie.Tests/DeleteModelTests.cs b/RazorPagesMovie.Tests/DeleteModelTests.cs deleted file mode 100644 index ef716b6..0000000 --- a/RazorPagesMovie.Tests/DeleteModelTests.cs +++ /dev/null @@ -1,193 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.EntityFrameworkCore; -using Moq; -using RazorPagesMovie.Data; -using RazorPagesMovie.Models; -using Xunit; -using Xunit.Abstractions; -using RazorPagesMovie.Pages.Movies; - -namespace RazorPagesMovie.Tests -{ - public class DeleteModelTests - { - private readonly Mock _mockContext; - private readonly DeleteModel _deleteModel; - private readonly ITestOutputHelper _output; - - public DeleteModelTests(ITestOutputHelper output) - { - _mockContext = new Mock(new DbContextOptions()); - _deleteModel = new DeleteModel(_mockContext.Object); - _output = output; - } - - private DbContextOptions GetContextOptions() - { - return new DbContextOptionsBuilder() - .UseInMemoryDatabase(databaseName: System.Guid.NewGuid().ToString()) - .Options; - } - - [Fact] - // a method that creates a movie, then deletes it from the database and check before and after the deletion - use _output.WriteLine with the same formatting as the other tests - public async Task CanDeleteMovie() - { - // Arrange - var options = GetContextOptions(); - using (var context = new RazorPagesMovieContext(options)) - { - context.Movie.Add(new Movie { Title = "Test Movie", Genre = "Test Genre", Price = 10M, ReleaseDate = DateTime.Now }); - context.SaveChanges(); - } - - // Act - using (var context = new RazorPagesMovieContext(options)) - { - var movie = await context.Movie.FirstOrDefaultAsync(m => m.Title == "Test Movie"); - _output.WriteLine("=== Test Output ==="); - if (movie != null) - { - _output.WriteLine($"Initial Movie: {movie.Title}"); - _output.WriteLine("Rmoving movie from the database..."); - context.Movie.Remove(movie); - await context.SaveChangesAsync(); - } - else - { - _output.WriteLine("Movie not found."); - } - - _output.WriteLine("Assert.NotNull(movie);"); - Assert.NotNull(movie); - } - - // Assert - check that the movie is deleted from the database - using (var context = new RazorPagesMovieContext(options)) - { - var movie = await context.Movie.FirstOrDefaultAsync(m => m.Title == "Test Movie"); - // convert movie object to string to print it - string str_movie = movie?.ToString() ?? "null"; - _output.WriteLine($"Deleted Movie: {str_movie}"); - _output.WriteLine("Assert.Null(movie);"); - Assert.Null(movie); - _output.WriteLine("==================="); - } - } - - [Fact] - // a method that creates a movie, then tries to delete a movie that doesn't exist in the database and check before and after the deletion - use _output.WriteLine with the same formatting as the other tests - public async Task CanNotDeleteMovie() - { - // Arrange - var options = GetContextOptions(); - using (var context = new RazorPagesMovieContext(options)) - { - context.Movie.Add(new Movie { Title = "Test Movie", Genre = "Test Genre", Price = 10M, ReleaseDate = DateTime.Now }); - context.SaveChanges(); - } - - // Act - using (var context = new RazorPagesMovieContext(options)) - { - var movie = await context.Movie.FirstOrDefaultAsync(m => m.Title == "Test Movie"); - _output.WriteLine("=== Test Output ==="); - if (movie != null) - { - _output.WriteLine($"Initial Movie: {movie.Title}"); - _output.WriteLine("Rmoving movie from the database..."); - context.Movie.Remove(movie); - await context.SaveChangesAsync(); - } - else - { - _output.WriteLine("Movie not found."); - } - - _output.WriteLine("Assert.NotNull(movie);"); - Assert.NotNull(movie); - } - - // Assert - check that the movie is deleted from the database - using (var context = new RazorPagesMovieContext(options)) - { - var movie = await context.Movie.FirstOrDefaultAsync(m => m.Title == "Test Movie"); - // convert movie object to string to print it - string str_movie = movie?.ToString() ?? "null"; - _output.WriteLine($"Deleted Movie: {str_movie}"); - _output.WriteLine("Assert.Null(movie);"); - Assert.Null(movie); - _output.WriteLine("Trying to fetch the movie by title: 'Test Movie' again, expected null"); - _output.WriteLine("Fetch by expression: context.Movie.FirstOrDefaultAsync(m => m.Title == 'Test Movie');"); - var movie2 = await context.Movie.FirstOrDefaultAsync(m => m.Title == "Test Movie"); - _output.WriteLine("Assert.Null(movie);"); - Assert.Null(movie2); - _output.WriteLine("==================="); - } - } - - [Fact] - public async Task CanHandleDeadlock() - { - // Arrange - var options = GetContextOptions(); - using (var context = new RazorPagesMovieContext(options)) - { - context.Movie.Add(new Movie { Title = "Test Movie", Genre = "Test Genre", Price = 10M, ReleaseDate = DateTime.Now }); - context.SaveChanges(); - } - - // Act - _output.WriteLine($"=== Test Output: ==="); - var tasks = new List(); - for (int i = 0; i < 10; i++) - { - int iteration = i + 1; - tasks.Add(Task.Run(async () => - { - using (var context = new RazorPagesMovieContext(options)) - { - var movie = await context.Movie.FirstOrDefaultAsync(m => m.Title == "Test Movie"); - if (movie != null) - { - _output.WriteLine($"Initial Movie: {movie.Title} (Iteration: {iteration})"); - _output.WriteLine("Removing movie from the database..."); - context.Movie.Remove(movie); - try - { - await context.SaveChangesAsync(); - _output.WriteLine($"Movie removed successfully (Iteration: {iteration})."); - } - catch (DbUpdateConcurrencyException) - { - _output.WriteLine($"Concurrency exception occurred (Iteration: {iteration})."); - } - } - else - { - _output.WriteLine($"Movie not found (Iteration: {iteration})."); - } - } - })); - } - - await Task.WhenAll(tasks); - - // Assert - check that the movie is deleted from the database - using (var context = new RazorPagesMovieContext(options)) - { - var movie = await context.Movie.FirstOrDefaultAsync(m => m.Title == "Test Movie"); - string str_movie = movie?.ToString() ?? "null"; - _output.WriteLine($"Deleted Movie: {str_movie}"); - _output.WriteLine("Assert.Null(movie);"); - Assert.Null(movie); - _output.WriteLine("==================="); - } - } - } -} diff --git a/RazorPagesMovie.Tests/DetailsModelTests.cs b/RazorPagesMovie.Tests/DetailsModelTests.cs deleted file mode 100644 index f7714f9..0000000 --- a/RazorPagesMovie.Tests/DetailsModelTests.cs +++ /dev/null @@ -1,234 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; -using RazorPagesMovie.Models; -using RazorPagesMovie.Data; -using RazorPagesMovie.Pages.Movies; -using Xunit.Abstractions; -using Microsoft.AspNetCore.Mvc; - -namespace RazorPagesMovie.Tests -{ - public class DetailsModelTests - { - private readonly ITestOutputHelper _output; - - public DetailsModelTests(ITestOutputHelper output) - { - _output = output; - } - - private DbContextOptions CreateNewContextOptions() - { - return new DbContextOptionsBuilder() - .UseInMemoryDatabase(databaseName: System.Guid.NewGuid().ToString()) - .Options; - } - - [Fact] - public void DetailsModel_CanBeInstantiated() - { - // Arrange - var options = CreateNewContextOptions(); - var mockLogger = new Mock>(); - var context = new RazorPagesMovieContext(options); - _output.WriteLine("=== Test Output ==="); - _output.WriteLine("Creating a mock DetailsModel object and asserting that it is not null:"); - // Act - var detailsModel = new DetailsModel(context, mockLogger.Object); - // Assert that the model is not null - Assert.NotNull(detailsModel); - _output.WriteLine("a. Asserting that the model is not null:"); - _output.WriteLine(" Assert.NotNull(model);"); - _output.WriteLine("b. Asserting that the model is of type PageModel:"); - _output.WriteLine(" Assert.IsType(model);"); - Assert.IsType(detailsModel); - _output.WriteLine("==================="); - } - - [Fact] - public void DetailsModel_OnGetAsync_ReturnsNotFoundResult_WhenIdIsNull() - { - _output.WriteLine("=== Test Output ==="); - _output.WriteLine("Creating a mock DetailsModel object and calling OnGetAsync with a null id:"); - // Arrange - var options = CreateNewContextOptions(); - var mockLogger = new Mock>(); - var context = new RazorPagesMovieContext(options); - var detailsModel = new DetailsModel(context, mockLogger.Object); - // Act - var result = detailsModel.OnGetAsync(null).Result; - // Assert - // Assert that the result is of type NotFoundResult - Assert.IsType(result); - _output.WriteLine("a. Asserting that the result is of type NotFoundResult:"); - _output.WriteLine(" Assert.IsType(result);"); - // Assert that the result is of type NotFoundResult - Assert.IsType(result); - _output.WriteLine("b. Asserting that the result is of type NotFoundResult:"); - _output.WriteLine(" Assert.IsType(result);"); - _output.WriteLine("==================="); - } - - [Fact] - public async Task DetailsModel_OnGetAsync_ReturnsNotFoundResult_WhenMovieIsNull() - { - _output.WriteLine("=== Test Output ==="); - _output.WriteLine("Creating a mock DetailsModel object and calling OnGetAsync with a null movie:"); - // Arrange - var options = CreateNewContextOptions(); - var mockLogger = new Mock>(); - var context = new RazorPagesMovieContext(options); - var detailsModel = new DetailsModel(context, mockLogger.Object); - // Act - var result = await detailsModel.OnGetAsync(1); - // Assert - // Assert that the result is of type NotFoundResult - Assert.IsType(result); - _output.WriteLine("a. Asserting that the result is of type NotFoundResult:"); - _output.WriteLine(" Assert.IsType(result);"); - // Assert that the result is of type NotFoundResult - Assert.IsType(result); - _output.WriteLine("b. Asserting that the result is of type NotFoundResult:"); - _output.WriteLine(" Assert.IsType(result);"); - _output.WriteLine("==================="); - } - - [Fact] - public async Task DetailsModel_OnGetAsync_ReturnsPageResult_WhenMovieIsNotNull() - { - _output.WriteLine("=== Test Output ==="); - _output.WriteLine("Creating a mock DetailsModel object and calling OnGetAsync with a movie:"); - // Arrange - var options = CreateNewContextOptions(); - var mockLogger = new Mock>(); - var context = new RazorPagesMovieContext(options); - var detailsModel = new DetailsModel(context, mockLogger.Object); - var movie = new Movie - { - Id = 1, - Title = "Inception", - ReleaseDate = DateTime.Parse("2010-07-16"), - Genre = "Sci-Fi", - Price = 10.99M - }; - context.Movie.Add(movie); - context.SaveChanges(); - - // Act - var result = await detailsModel.OnGetAsync(1); - // Assert - // Assert that the result is of type PageResult - Assert.IsType(result); - _output.WriteLine("a. Asserting that the result is of type PageResult:"); - _output.WriteLine(" Assert.IsType(result);"); - // Assert that the result is of type PageResult - Assert.IsType(result); - _output.WriteLine("b. Asserting that the result is of type PageResult:"); - _output.WriteLine(" Assert.IsType(result);"); - _output.WriteLine("==================="); - } - - [Fact] - public async Task DetailsModel_OnGetAsync_ReturnsPageResult_WhenMovieIsNotNullAndIdIsNotValid() - { - _output.WriteLine("=== Test Output ==="); - _output.WriteLine("Creating a mock DetailsModel object and calling OnGetAsync with a movie and an invalid id:"); - // Arrange - var options = CreateNewContextOptions(); - var mockLogger = new Mock>(); - var context = new RazorPagesMovieContext(options); - var detailsModel = new DetailsModel(context, mockLogger.Object); - var movie = new Movie - { - Id = 1, - Title = "Inception", - ReleaseDate = DateTime.Parse("2010-07-16"), - Genre = "Sci-Fi", - Price = 10.99M - }; - context.Movie.Add(movie); - context.SaveChanges(); - - // Act - // Call OnGetAsync with an invalid id - var result = await detailsModel.OnGetAsync(2); - // Assert - // Assert that the result is invalid id - _output.WriteLine("a. Asserting that the result is of type NotFoundResult:"); - _output.WriteLine(" Assert.IsType(result);"); - // Assert that the result is of type NotFoundResult - Assert.IsType(result); - _output.WriteLine("b. Asserting that the result is of type NotFoundResult:"); - _output.WriteLine(" Assert.IsType(result);"); - _output.WriteLine("==================="); - } - - [Fact] - public async Task DetailsModel_OnGetAsync_ReturnsPageResult_WhenMovieIsNotNullAndIdIsValid() - { - _output.WriteLine("=== Test Output ==="); - _output.WriteLine("Creating a mock DetailsModel object and calling OnGetAsync with a movie and a valid id:"); - // Arrange - var options = CreateNewContextOptions(); - var mockLogger = new Mock>(); - var context = new RazorPagesMovieContext(options); - var detailsModel = new DetailsModel(context, mockLogger.Object); - var movie = new Movie - { - Id = 1, - Title = "Inception", - ReleaseDate = DateTime.Parse("2010-07-16"), - Genre = "Sci-Fi", - Price = 10.99M - }; - context.Movie.Add(movie); - context.SaveChanges(); - - // Act - // Call OnGetAsync with a valid id - var result = await detailsModel.OnGetAsync(1); - // Assert - // Assert that the result is of type PageResult - Assert.IsType(result); - _output.WriteLine("a. Asserting that the result is of type PageResult:"); - _output.WriteLine(" Assert.IsType(result);"); - // Assert that the result is of type PageResult - Assert.IsType(result); - _output.WriteLine("b. Asserting that the result is of type PageResult:"); - _output.WriteLine(" Assert.IsType(result);"); - // Assert that the movie is not null - Assert.NotNull(detailsModel.Movie); - _output.WriteLine("c. Asserting that the movie is not null:"); - _output.WriteLine(" Assert.NotNull(detailsModel.Movie);"); - // Assert that the movie is of type Movie - Assert.IsType(detailsModel.Movie); - _output.WriteLine("d. Asserting that the movie is of type Movie:"); - _output.WriteLine(" Assert.IsType(detailsModel.Movie);"); - // Assert that the movie has the correct id - Assert.Equal(1, detailsModel.Movie.Id); - _output.WriteLine("e. Asserting that the movie has the correct id:"); - _output.WriteLine(" Assert.Equal(1, detailsModel.Movie.Id);"); - // Assert that the movie has the correct title - Assert.Equal("Inception", detailsModel.Movie.Title); - _output.WriteLine("f. Asserting that the movie has the correct title:"); - _output.WriteLine(" Assert.Equal(\"Inception\", detailsModel.Movie.Title);"); - // Assert that the movie has the correct release date - Assert.Equal(DateTime.Parse("2010-07-16"), detailsModel.Movie.ReleaseDate); - _output.WriteLine("g. Asserting that the movie has the correct release date:"); - _output.WriteLine(" Assert.Equal(DateTime.Parse(\"2010-07-16\"), detailsModel.Movie.ReleaseDate);"); - // Assert that the movie has the correct genre - Assert.Equal("Sci-Fi", detailsModel.Movie.Genre); - _output.WriteLine("h. Asserting that the movie has the correct genre:"); - _output.WriteLine(" Assert.Equal(\"Sci-Fi\", detailsModel.Movie.Genre);"); - // Assert that the movie has the correct price - Assert.Equal(10.99M, detailsModel.Movie.Price); - _output.WriteLine("i. Asserting that the movie has the correct price:"); - _output.WriteLine(" Assert.Equal(10.99M, detailsModel.Movie.Price);"); - _output.WriteLine("==================="); - } - } -} \ No newline at end of file diff --git a/RazorPagesMovie.csproj b/RazorPagesMovie.csproj deleted file mode 100644 index 62d6e70..0000000 --- a/RazorPagesMovie.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - net6.0 - enable - enable - true - - - - - - - - - - - - - - - PreserveNewest - - - - \ No newline at end of file diff --git a/RazorPagesMovie.sln b/RazorPagesMovie.sln new file mode 100644 index 0000000..1ba343f --- /dev/null +++ b/RazorPagesMovie.sln @@ -0,0 +1,40 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{7CE8AFD5-777C-46E7-ABF6-2ECD8E931C4F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RazorPagesMovie.Tests", "tests\RazorPagesMovie.Tests\RazorPagesMovie.Tests.csproj", "{CD5D3E3F-0111-46AA-A383-187B644B0CAB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RazorPagesMovie.UITests", "tests\RazorPagesMovie.UITests\RazorPagesMovie.UITests.csproj", "{F0DAF522-CB92-4F31-A2E9-A706AAB1C171}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RazorPagesMovie", "src\RazorPagesMovie.csproj", "{55715C47-701C-4A44-A9F1-F150A5924690}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CD5D3E3F-0111-46AA-A383-187B644B0CAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CD5D3E3F-0111-46AA-A383-187B644B0CAB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD5D3E3F-0111-46AA-A383-187B644B0CAB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CD5D3E3F-0111-46AA-A383-187B644B0CAB}.Release|Any CPU.Build.0 = Release|Any CPU + {F0DAF522-CB92-4F31-A2E9-A706AAB1C171}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F0DAF522-CB92-4F31-A2E9-A706AAB1C171}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F0DAF522-CB92-4F31-A2E9-A706AAB1C171}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F0DAF522-CB92-4F31-A2E9-A706AAB1C171}.Release|Any CPU.Build.0 = Release|Any CPU + {55715C47-701C-4A44-A9F1-F150A5924690}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {55715C47-701C-4A44-A9F1-F150A5924690}.Debug|Any CPU.Build.0 = Debug|Any CPU + {55715C47-701C-4A44-A9F1-F150A5924690}.Release|Any CPU.ActiveCfg = Release|Any CPU + {55715C47-701C-4A44-A9F1-F150A5924690}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {CD5D3E3F-0111-46AA-A383-187B644B0CAB} = {7CE8AFD5-777C-46E7-ABF6-2ECD8E931C4F} + {F0DAF522-CB92-4F31-A2E9-A706AAB1C171} = {7CE8AFD5-777C-46E7-ABF6-2ECD8E931C4F} + EndGlobalSection +EndGlobal diff --git a/RazorPagesMovie90.generated.sln b/RazorPagesMovie90.generated.sln deleted file mode 100644 index 63c9fd0..0000000 --- a/RazorPagesMovie90.generated.sln +++ /dev/null @@ -1,31 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.5.002.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RazorPagesMovie", "RazorPagesMovie.csproj", "{9178D763-CE9C-4D23-A1E9-E66024F93C6B}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RazorPagesMovie.Tests", "RazorPagesMovie.Tests\RazorPagesMovie.Tests.csproj", "{FA44B8A9-4CA7-4B76-9B40-337F2BCA0645}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {9178D763-CE9C-4D23-A1E9-E66024F93C6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9178D763-CE9C-4D23-A1E9-E66024F93C6B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9178D763-CE9C-4D23-A1E9-E66024F93C6B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9178D763-CE9C-4D23-A1E9-E66024F93C6B}.Release|Any CPU.Build.0 = Release|Any CPU - {FA44B8A9-4CA7-4B76-9B40-337F2BCA0645}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FA44B8A9-4CA7-4B76-9B40-337F2BCA0645}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FA44B8A9-4CA7-4B76-9B40-337F2BCA0645}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FA44B8A9-4CA7-4B76-9B40-337F2BCA0645}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {A75B596F-88DF-455E-9C86-6703B16A2CFB} - EndGlobalSection -EndGlobal diff --git a/appsettings.Development.json b/appsettings.Development.json deleted file mode 100644 index d6a94ce..0000000 --- a/appsettings.Development.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "DetailedErrors": true, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "Kestrel": { - "Endpoints": { - "Http": { - "Url": "http://localhost:5000" - }, - "Https": { - "Url": "https://localhost:5001" - } - } - } -} diff --git a/appsettings.json b/appsettings.json deleted file mode 100644 index d0e04c0..0000000 --- a/appsettings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "ConnectionStrings": { - "RazorPagesMovieContext": "Server={DB_SERVER},{DB_PORT};Database={DB_NAME};User ID={DB_USER};Password={DB_PASSWORD};TrustServerCertificate=True;" - } -} \ No newline at end of file diff --git a/appsettings_SQLite.Development.json b/appsettings_SQLite.Development.json deleted file mode 100644 index 9e53786..0000000 --- a/appsettings_SQLite.Development.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "DetailedErrors": true, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} - diff --git a/appsettings_SQLite.json b/appsettings_SQLite.json deleted file mode 100644 index 8bdd815..0000000 --- a/appsettings_SQLite.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "ConnectionStrings": { - "RazorPagesMovieContext": "Data Source=RazorPagesMovie.Data.db" - } -} \ No newline at end of file diff --git a/src/Data/RazorPagesMovieContext.cs b/src/Data/RazorPagesMovieContext.cs new file mode 100644 index 0000000..3663ec1 --- /dev/null +++ b/src/Data/RazorPagesMovieContext.cs @@ -0,0 +1,112 @@ +using Microsoft.EntityFrameworkCore; +using RazorPagesMovie.Models; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Cryptography.KeyDerivation; + +namespace RazorPagesMovie.Data +{ + public class RazorPagesMovieContext : DbContext + { + public RazorPagesMovieContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Movie { get; set; } = default!; + public DbSet Users { get; set; } = default!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // User configuration + modelBuilder.Entity(entity => + { + entity.HasIndex(e => e.Username).IsUnique(); + entity.Property(e => e.Username).IsRequired().HasMaxLength(50); + entity.Property(e => e.Password).IsRequired().HasMaxLength(100); + entity.Property(e => e.Role).IsRequired(); + entity.Property(e => e.Timestamp).IsConcurrencyToken(); + }); + + // Movie configuration + modelBuilder.Entity(entity => + { + entity.HasOne(m => m.User) + .WithMany(u => u.Movies) + .HasForeignKey(m => m.UserId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasIndex(m => m.UserId); + entity.Property(m => m.Timestamp).IsConcurrencyToken(); + entity.Property(m => m.Timestamp).HasDefaultValue(new byte[8]); // Set default value for Timestamp + }); + + // Seed data with hashed passwords + var hashedAdminPw = HashPassword("password"); + var hashedUserPw = HashPassword("password"); + + // Seed admin users + modelBuilder.Entity().HasData(new User + { + Id = 1, + Username = "admin", + Password = "password", + Role = UserRole.Admin, + Timestamp = new byte[8] + }); + + for (int i = 2; i <= 10; i++) + { + modelBuilder.Entity().HasData(new User + { + Id = i, + Username = $"admin{i - 1}", + Password = "password", + Role = UserRole.Admin, + Timestamp = new byte[8] + }); + } + + // Seed standard users + modelBuilder.Entity().HasData(new User + { + Id = 11, + Username = "user", + Password = "password", + Role = UserRole.Standard, + Timestamp = new byte[8] + }); + + for (int i = 12; i <= 20; i++) + { + modelBuilder.Entity().HasData(new User + { + Id = i, + Username = $"user{i - 11}", + Password = "password", + Role = UserRole.Standard, + Timestamp = new byte[8] + }); + } + } + + private static string HashPassword(string password) + { + byte[] salt = new byte[128 / 8]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(salt); + } + + string hashed = Convert.ToBase64String(KeyDerivation.Pbkdf2( + password: password, + salt: salt, + prf: KeyDerivationPrf.HMACSHA256, + iterationCount: 100000, + numBytesRequested: 256 / 8)); + + return $"{Convert.ToBase64String(salt)}:{hashed}"; + } + } +} \ No newline at end of file diff --git a/src/Migrations/20241201195812_InitialCreate.Designer.cs b/src/Migrations/20241201195812_InitialCreate.Designer.cs new file mode 100644 index 0000000..8ac3ca4 --- /dev/null +++ b/src/Migrations/20241201195812_InitialCreate.Designer.cs @@ -0,0 +1,283 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RazorPagesMovie.Data; + +#nullable disable + +namespace RazorPagesMovie.Migrations +{ + [DbContext(typeof(RazorPagesMovieContext))] + [Migration("20241201195812_InitialCreate")] + partial class InitialCreate + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); + + modelBuilder.Entity("RazorPagesMovie.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); + + b.Property("Genre") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .HasColumnType("decimal(18,2)"); + + b.Property("Rating") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReleaseDate") + .HasColumnType("datetime2"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion") + .HasDefaultValue(new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }); + + b.Property("Title") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Movie"); + }); + + modelBuilder.Entity("RazorPagesMovie.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); + + b.Property("Password") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasData( + new + { + Id = 1, + Password = "password", + Role = 2, + Timestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }, + Username = "admin" + }, + new + { + Id = 2, + Password = "password", + Role = 2, + Timestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }, + Username = "admin1" + }, + new + { + Id = 3, + Password = "password", + Role = 2, + Timestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }, + Username = "admin2" + }, + new + { + Id = 4, + Password = "password", + Role = 2, + Timestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }, + Username = "admin3" + }, + new + { + Id = 5, + Password = "password", + Role = 2, + Timestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }, + Username = "admin4" + }, + new + { + Id = 6, + Password = "password", + Role = 2, + Timestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }, + Username = "admin5" + }, + new + { + Id = 7, + Password = "password", + Role = 2, + Timestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }, + Username = "admin6" + }, + new + { + Id = 8, + Password = "password", + Role = 2, + Timestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }, + Username = "admin7" + }, + new + { + Id = 9, + Password = "password", + Role = 2, + Timestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }, + Username = "admin8" + }, + new + { + Id = 10, + Password = "password", + Role = 2, + Timestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }, + Username = "admin9" + }, + new + { + Id = 11, + Password = "password", + Role = 0, + Timestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }, + Username = "user" + }, + new + { + Id = 12, + Password = "password", + Role = 0, + Timestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }, + Username = "user1" + }, + new + { + Id = 13, + Password = "password", + Role = 0, + Timestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }, + Username = "user2" + }, + new + { + Id = 14, + Password = "password", + Role = 0, + Timestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }, + Username = "user3" + }, + new + { + Id = 15, + Password = "password", + Role = 0, + Timestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }, + Username = "user4" + }, + new + { + Id = 16, + Password = "password", + Role = 0, + Timestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }, + Username = "user5" + }, + new + { + Id = 17, + Password = "password", + Role = 0, + Timestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }, + Username = "user6" + }, + new + { + Id = 18, + Password = "password", + Role = 0, + Timestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }, + Username = "user7" + }, + new + { + Id = 19, + Password = "password", + Role = 0, + Timestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }, + Username = "user8" + }, + new + { + Id = 20, + Password = "password", + Role = 0, + Timestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }, + Username = "user9" + }); + }); + + modelBuilder.Entity("RazorPagesMovie.Models.Movie", b => + { + b.HasOne("RazorPagesMovie.Models.User", "User") + .WithMany("Movies") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RazorPagesMovie.Models.User", b => + { + b.Navigation("Movies"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Migrations/20241201195812_InitialCreate.cs b/src/Migrations/20241201195812_InitialCreate.cs new file mode 100644 index 0000000..1331417 --- /dev/null +++ b/src/Migrations/20241201195812_InitialCreate.cs @@ -0,0 +1,101 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RazorPagesMovie.Migrations +{ + public partial class InitialCreate : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Username = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + Password = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Role = table.Column(type: "int", nullable: false), + Timestamp = table.Column(type: "rowversion", rowVersion: true, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Movie", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Title = table.Column(type: "nvarchar(max)", nullable: false), + ReleaseDate = table.Column(type: "datetime2", nullable: false), + Genre = table.Column(type: "nvarchar(max)", nullable: false), + Price = table.Column(type: "decimal(18,2)", nullable: false), + Rating = table.Column(type: "nvarchar(max)", nullable: false), + UserId = table.Column(type: "int", nullable: true), + Timestamp = table.Column(type: "rowversion", rowVersion: true, nullable: false, defaultValue: new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }) + }, + constraints: table => + { + table.PrimaryKey("PK_Movie", x => x.Id); + table.ForeignKey( + name: "FK_Movie_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "Users", + columns: new[] { "Id", "Password", "Role", "Username" }, + values: new object[,] + { + { 1, "password", 2, "admin" }, + { 2, "password", 2, "admin1" }, + { 3, "password", 2, "admin2" }, + { 4, "password", 2, "admin3" }, + { 5, "password", 2, "admin4" }, + { 6, "password", 2, "admin5" }, + { 7, "password", 2, "admin6" }, + { 8, "password", 2, "admin7" }, + { 9, "password", 2, "admin8" }, + { 10, "password", 2, "admin9" }, + { 11, "password", 0, "user" }, + { 12, "password", 0, "user1" }, + { 13, "password", 0, "user2" }, + { 14, "password", 0, "user3" }, + { 15, "password", 0, "user4" }, + { 16, "password", 0, "user5" }, + { 17, "password", 0, "user6" }, + { 18, "password", 0, "user7" }, + { 19, "password", 0, "user8" }, + { 20, "password", 0, "user9" } + }); + + migrationBuilder.CreateIndex( + name: "IX_Movie_UserId", + table: "Movie", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Users_Username", + table: "Users", + column: "Username", + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Movie"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/src/Models/Movie.cs b/src/Models/Movie.cs new file mode 100644 index 0000000..5a7df1c --- /dev/null +++ b/src/Models/Movie.cs @@ -0,0 +1,35 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using RazorPagesMovie.Models; + +namespace RazorPagesMovie.Models +{ + public class Movie + { + public int Id { get; set; } + + [Required] + public string Title { get; set; } = string.Empty; // Initialize with a default value + + [Required] + [DataType(DataType.Date)] + public DateTime ReleaseDate { get; set; } + + [Required] + public string Genre { get; set; } = string.Empty; // Initialize with a default value + + [Required] + [Column(TypeName = "decimal(18, 2)")] + public decimal Price { get; set; } + + [Required] + public string Rating { get; set; } = string.Empty; // Initialize with a default value + + public int? UserId { get; set; } + public User? User { get; set; } + + [Timestamp] + public byte[] Timestamp { get; set; } = new byte[8]; // Initialize with a default value + } +} \ No newline at end of file diff --git a/Models/SeedData.cs b/src/Models/SeedData.cs similarity index 61% rename from Models/SeedData.cs rename to src/Models/SeedData.cs index 8c38f71..0d3877e 100644 --- a/Models/SeedData.cs +++ b/src/Models/SeedData.cs @@ -18,7 +18,22 @@ public static void Initialize(IServiceProvider serviceProvider) throw new ArgumentNullException("Null RazorPagesMovieContext"); } - // Look for any movies. + // Add users if none exist + if (!context.Users.Any()) + { + context.Users.AddRange( + new User + { + Username = "demo", + Password = "demo123", // In production, use password hashing + Role = UserRole.Standard, + Timestamp = Array.Empty() + } + ); + context.SaveChanges(); + } + + // Look for any movies if (context.Movie.Any()) { return; // DB has been seeded @@ -31,7 +46,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("1989-2-12"), Genre = "Romantic Comedy", Price = 7.99M, - Rating = "R" + Rating = "R", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -40,7 +57,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("1984-3-13"), Genre = "Comedy", Price = 8.99M, - Rating = "G" + Rating = "G", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -49,7 +68,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("1986-2-23"), Genre = "Comedy", Price = 9.99M, - Rating = "G" + Rating = "G", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -58,7 +79,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("1959-4-15"), Genre = "Western", Price = 3.99M, - Rating = "NA" + Rating = "NA", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -67,7 +90,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("2010-7-16"), Genre = "Sci-Fi", Price = 9.99M, - Rating = "PG-13" + Rating = "PG-13", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -76,7 +101,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("2014-11-7"), Genre = "Sci-Fi", Price = 10.99M, - Rating = "PG-13" + Rating = "PG-13", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -85,7 +112,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("2008-7-18"), Genre = "Action", Price = 9.99M, - Rating = "PG-13" + Rating = "PG-13", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -94,7 +123,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("1999-3-31"), Genre = "Action", Price = 8.99M, - Rating = "R" + Rating = "R", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -103,7 +134,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("1994-10-14"), Genre = "Crime", Price = 7.99M, - Rating = "R" + Rating = "R", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -112,7 +145,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("1994-9-23"), Genre = "Drama", Price = 8.99M, - Rating = "R" + Rating = "R", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -121,7 +156,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("1994-7-6"), Genre = "Drama", Price = 7.99M, - Rating = "PG-13" + Rating = "PG-13", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -130,7 +167,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("1972-3-24"), Genre = "Crime", Price = 9.99M, - Rating = "R" + Rating = "R", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -139,7 +178,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("2001-12-19"), Genre = "Fantasy", Price = 10.99M, - Rating = "PG-13" + Rating = "PG-13", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -148,7 +189,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("1977-5-25"), Genre = "Sci-Fi", Price = 8.99M, - Rating = "PG" + Rating = "PG", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -157,7 +200,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("2014-11-7"), Genre = "Science Fiction", Price = 12.99M, - Rating = "PG-13" + Rating = "PG-13", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -166,7 +211,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("2000-5-5"), Genre = "Action", Price = 9.99M, - Rating = "R" + Rating = "R", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -175,7 +222,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("2006-10-20"), Genre = "Drama", Price = 8.99M, - Rating = "PG-13" + Rating = "PG-13", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -184,7 +233,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("2006-10-6"), Genre = "Crime", Price = 10.99M, - Rating = "R" + Rating = "R", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -193,7 +244,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("2014-10-10"), Genre = "Drama", Price = 9.99M, - Rating = "R" + Rating = "R", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -202,7 +255,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("2015-5-15"), Genre = "Action", Price = 11.99M, - Rating = "R" + Rating = "R", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -211,7 +266,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("2010-10-1"), Genre = "Drama", Price = 8.99M, - Rating = "PG-13" + Rating = "PG-13", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -220,7 +277,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("2019-5-30"), Genre = "Thriller", Price = 12.99M, - Rating = "R" + Rating = "R", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -229,7 +288,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("2017-11-22"), Genre = "Animation", Price = 7.99M, - Rating = "PG" + Rating = "PG", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -238,7 +299,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("2014-3-28"), Genre = "Comedy", Price = 9.99M, - Rating = "R" + Rating = "R", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -247,7 +310,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("1998-6-5"), Genre = "Drama", Price = 8.99M, - Rating = "PG" + Rating = "PG", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -256,7 +321,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("1999-12-10"), Genre = "Drama", Price = 9.99M, - Rating = "R" + Rating = "R", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -265,7 +332,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("1998-3-6"), Genre = "Comedy", Price = 7.99M, - Rating = "R" + Rating = "R", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -274,7 +343,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("1995-5-24"), Genre = "Drama", Price = 9.99M, - Rating = "R" + Rating = "R", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -283,7 +354,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("1995-11-22"), Genre = "Animation", Price = 6.99M, - Rating = "G" + Rating = "G", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -292,7 +365,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("1993-12-15"), Genre = "Drama", Price = 10.99M, - Rating = "R" + Rating = "R", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -301,7 +376,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("1990-9-19"), Genre = "Crime", Price = 9.99M, - Rating = "R" + Rating = "R", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -310,7 +387,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("2013-12-25"), Genre = "Biography", Price = 11.99M, - Rating = "R" + Rating = "R", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -319,7 +398,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("2004-11-5"), Genre = "Animation", Price = 8.99M, - Rating = "PG" + Rating = "PG", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user }, new Movie @@ -328,7 +409,9 @@ public static void Initialize(IServiceProvider serviceProvider) ReleaseDate = DateTime.Parse("2003-5-30"), Genre = "Animation", Price = 7.99M, - Rating = "G" + Rating = "G", + Timestamp = Array.Empty(), + UserId = context.Users.First().Id // Associate with demo user } ); context.SaveChanges(); diff --git a/src/Models/User.cs b/src/Models/User.cs new file mode 100644 index 0000000..0da029c --- /dev/null +++ b/src/Models/User.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace RazorPagesMovie.Models +{ + public class User + { + public int Id { get; set; } + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public UserRole Role { get; set; } = UserRole.Standard; + + [Timestamp] + public byte[]? Timestamp { get; set; } + + public virtual ICollection Movies { get; set; } = new List(); + } +} diff --git a/src/Models/UserRole.cs b/src/Models/UserRole.cs new file mode 100644 index 0000000..8ece11b --- /dev/null +++ b/src/Models/UserRole.cs @@ -0,0 +1,9 @@ +namespace RazorPagesMovie.Models +{ + public enum UserRole + { + Standard = 0, + Premium = 1, + Admin = 2 + } +} \ No newline at end of file diff --git a/src/MySolution.sln b/src/MySolution.sln new file mode 100644 index 0000000..71a4dce --- /dev/null +++ b/src/MySolution.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RazorPagesMovie", "RazorPagesMovie.csproj", "{A964C2FC-1950-4A0B-B314-2FEBCC9C2315}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RazorPagesMovie.UITests", "RazorPagesMovie.UITests\RazorPagesMovie.UITests.csproj", "{52C6A6B3-6073-4057-B293-B7FD65DF5C70}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RazorPagesMovie.Tests", "RazorPagesMovie.Tests\RazorPagesMovie.Tests.csproj", "{4E568B06-2DAA-4078-85E3-B30BFEBAA389}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A964C2FC-1950-4A0B-B314-2FEBCC9C2315}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A964C2FC-1950-4A0B-B314-2FEBCC9C2315}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A964C2FC-1950-4A0B-B314-2FEBCC9C2315}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A964C2FC-1950-4A0B-B314-2FEBCC9C2315}.Release|Any CPU.Build.0 = Release|Any CPU + {52C6A6B3-6073-4057-B293-B7FD65DF5C70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {52C6A6B3-6073-4057-B293-B7FD65DF5C70}.Debug|Any CPU.Build.0 = Debug|Any CPU + {52C6A6B3-6073-4057-B293-B7FD65DF5C70}.Release|Any CPU.ActiveCfg = Release|Any CPU + {52C6A6B3-6073-4057-B293-B7FD65DF5C70}.Release|Any CPU.Build.0 = Release|Any CPU + {4E568B06-2DAA-4078-85E3-B30BFEBAA389}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4E568B06-2DAA-4078-85E3-B30BFEBAA389}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4E568B06-2DAA-4078-85E3-B30BFEBAA389}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4E568B06-2DAA-4078-85E3-B30BFEBAA389}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/src/Pages/Account/Login.cshtml b/src/Pages/Account/Login.cshtml new file mode 100644 index 0000000..1d190e9 --- /dev/null +++ b/src/Pages/Account/Login.cshtml @@ -0,0 +1,35 @@ +@page +@model RazorPagesMovie.Pages.Account.LoginModel +@{ + ViewData["Title"] = "Login"; +} + +

Login

+ +
+
+
+
+ + + +
+
+ + + +
+
+ +
+
+ @if (Model.ErrorMessage != null) + { +
@Model.ErrorMessage
+ } +
+
+ +@section Scripts { + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} +} \ No newline at end of file diff --git a/src/Pages/Account/Login.cshtml.cs b/src/Pages/Account/Login.cshtml.cs new file mode 100644 index 0000000..104f535 --- /dev/null +++ b/src/Pages/Account/Login.cshtml.cs @@ -0,0 +1,90 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using RazorPagesMovie.Data; +using RazorPagesMovie.Models; +using System.ComponentModel.DataAnnotations; + +namespace RazorPagesMovie.Pages.Account +{ + public class LoginModel : PageModel + { + private readonly RazorPagesMovieContext _context; + private readonly ILogger _logger; + + public LoginModel(RazorPagesMovieContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + [BindProperty] + public LoginInput LoginInput { get; set; } = default!; + + public string? ErrorMessage { get; set; } + + public void OnGet() + { + // Clear any existing error message + ErrorMessage = null; + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + ErrorMessage = "Invalid input. Please check your username and password."; + _logger.LogWarning("Invalid input: {ModelStateErrors}", ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage)); + return Page(); + } + + var user = await _context.Users + .FirstOrDefaultAsync(u => u.Username == LoginInput.Username); + + if (user == null) + { + ErrorMessage = "Invalid username or password"; + _logger.LogWarning("Login failed: Invalid username '{Username}'", LoginInput.Username); + return Page(); + } + + if (user.Password != LoginInput.Password) + { + ErrorMessage = "Invalid username or password"; + _logger.LogWarning("Login failed: Invalid password for username '{Username}'", LoginInput.Username); + return Page(); + } + + // Set session variables + HttpContext.Session.SetInt32("UserId", user.Id); + HttpContext.Session.SetString("Username", user.Username); + + // Retrieve user role + var role = user.Role.ToString(); + + // Log user role to console + Console.WriteLine($"User {user.Username} logged in at {DateTime.UtcNow} with role: {role}"); + + // Log user role using logger + _logger.LogInformation("User {Username} logged in at {Time} with role: {Role}", + user.Username, DateTime.UtcNow, role); + + // Store role in Session + HttpContext.Session.SetString("UserRole", role); + + return RedirectToPage("/Movies/Index"); + } + } + + public class LoginInput + { + [Required] + [Display(Name = "Username")] + public string Username { get; set; } = string.Empty; + + [Required] + [DataType(DataType.Password)] + [Display(Name = "Password")] + public string Password { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/src/Pages/Account/Logout.cshtml b/src/Pages/Account/Logout.cshtml new file mode 100644 index 0000000..90eb7b4 --- /dev/null +++ b/src/Pages/Account/Logout.cshtml @@ -0,0 +1,5 @@ +@page +@model RazorPagesMovie.Pages.Account.LogoutModel +@{ + Layout = null; +} \ No newline at end of file diff --git a/src/Pages/Account/Logout.cshtml.cs b/src/Pages/Account/Logout.cshtml.cs new file mode 100644 index 0000000..48d4800 --- /dev/null +++ b/src/Pages/Account/Logout.cshtml.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace RazorPagesMovie.Pages.Account +{ + public class LogoutModel : PageModel + { + public IActionResult OnGet() + { + HttpContext.Session.Clear(); + return RedirectToPage("/Index"); + } + } +} diff --git a/src/Pages/Account/Register.cshtml b/src/Pages/Account/Register.cshtml new file mode 100644 index 0000000..464248d --- /dev/null +++ b/src/Pages/Account/Register.cshtml @@ -0,0 +1,40 @@ +@page +@model RazorPagesMovie.Pages.Account.RegisterModel +@using RazorPagesMovie.Models +@{ + ViewData["Title"] = "Register"; +} + +

Register

+ +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ + +} + +
+
+ + + +
+
+ + + +
+ @if (HttpContext.Session.GetString("UserRole") == "Admin") + { +
+ + +
+ } + +
+ +@section Scripts { + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} +} \ No newline at end of file diff --git a/src/Pages/Account/Register.cshtml.cs b/src/Pages/Account/Register.cshtml.cs new file mode 100644 index 0000000..bfd28a1 --- /dev/null +++ b/src/Pages/Account/Register.cshtml.cs @@ -0,0 +1,78 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using RazorPagesMovie.Data; +using RazorPagesMovie.Models; +using System.ComponentModel.DataAnnotations; + +namespace RazorPagesMovie.Pages.Account +{ + public class RegisterModel : PageModel + { + private readonly RazorPagesMovieContext _context; + private readonly ILogger _logger; + + public RegisterModel(RazorPagesMovieContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + [BindProperty] + public RegisterInput RegisterUser { get; set; } = default!; + + public string? ErrorMessage { get; set; } + + public void OnGet() + { + ErrorMessage = null; + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + return Page(); + } + + if (await _context.Users.AnyAsync(u => u.Username == RegisterUser.Username)) + { + ErrorMessage = "Username already exists"; + return Page(); + } + + var user = new User + { + Username = RegisterUser.Username, + Password = RegisterUser.Password, + Role = UserRole.Standard + }; + + _context.Users.Add(user); + await _context.SaveChangesAsync(); + + HttpContext.Session.SetInt32("UserId", user.Id); + HttpContext.Session.SetString("Username", user.Username); + HttpContext.Session.SetString("UserRole", user.Role.ToString()); + + return RedirectToPage("/Index"); + } + } + + public class RegisterInput + { + [Required] + [StringLength(100, MinimumLength = 3)] + [Display(Name = "Username")] + public string Username { get; set; } = string.Empty; + + [Required] + [StringLength(100, MinimumLength = 6)] + [DataType(DataType.Password)] + [Display(Name = "Password")] + public string Password { get; set; } = string.Empty; + + [Display(Name = "Role")] + public UserRole Role { get; set; } = UserRole.Standard; + } +} \ No newline at end of file diff --git a/Pages/Error.cshtml b/src/Pages/Error.cshtml similarity index 100% rename from Pages/Error.cshtml rename to src/Pages/Error.cshtml diff --git a/Pages/Error.cshtml.cs b/src/Pages/Error.cshtml.cs similarity index 100% rename from Pages/Error.cshtml.cs rename to src/Pages/Error.cshtml.cs diff --git a/Pages/Index.cshtml b/src/Pages/Index.cshtml similarity index 100% rename from Pages/Index.cshtml rename to src/Pages/Index.cshtml diff --git a/Pages/Index.cshtml.cs b/src/Pages/Index.cshtml.cs similarity index 100% rename from Pages/Index.cshtml.cs rename to src/Pages/Index.cshtml.cs diff --git a/src/Pages/Movies/AddFavorites.cshtml.cs b/src/Pages/Movies/AddFavorites.cshtml.cs new file mode 100644 index 0000000..f379557 --- /dev/null +++ b/src/Pages/Movies/AddFavorites.cshtml.cs @@ -0,0 +1,92 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using RazorPagesMovie.Data; +using RazorPagesMovie.Models; + +namespace RazorPagesMovie.Pages.Movies +{ + public class AddToFavoritesModel : PageModel + { + private readonly RazorPagesMovieContext _context; + + public AddToFavoritesModel(RazorPagesMovieContext context) + { + _context = context; + } + + [BindProperty] + public Movie? Movie { get; set; } + + public async Task OnGetAsync(int? id) + { + if (id == null) + { + return NotFound(); + } + + Movie = await _context.Movie.FirstOrDefaultAsync(m => m.Id == id); + + if (Movie == null) + { + return NotFound(); + } + + // Check if user is authenticated first + if (User?.Identity?.Name == null) + { + return NotFound(); + } + + var user = await _context.Users + .Include(u => u.Movies) + .FirstOrDefaultAsync(u => u.Username == User.Identity.Name); + + if (user == null) + { + return NotFound(); + } + + user.Movies.Add(Movie); + await _context.SaveChangesAsync(); + + return RedirectToPage("./Index"); + } + + public async Task OnPostAsync(int id) + { + if (!ModelState.IsValid) + { + return Page(); + } + + Movie = await _context.Movie.FirstOrDefaultAsync(m => m.Id == id); + + if (Movie == null) + { + return NotFound(); + } + + // Check if user is authenticated first + if (User?.Identity?.Name == null) + { + return NotFound(); + } + + var user = await _context.Users + .Include(u => u.Movies) + .FirstOrDefaultAsync(u => u.Username == User.Identity.Name); + + if (user == null) + { + return NotFound(); + } + + user.Movies.Add(Movie); + await _context.SaveChangesAsync(); + + return RedirectToPage("./Index"); + } + } +} \ No newline at end of file diff --git a/src/Pages/Movies/AddToFavorites.cshtml b/src/Pages/Movies/AddToFavorites.cshtml new file mode 100644 index 0000000..ee0d863 --- /dev/null +++ b/src/Pages/Movies/AddToFavorites.cshtml @@ -0,0 +1,14 @@ +@page +@model RazorPagesMovie.Pages.Movies.AddToFavoritesModel + +@{ + ViewData["Title"] = "Add to Favorites"; +} + +

Add to Favorites

+ +

Movie "@Model.Movie?.Title" has been added to your favorites.

+ + \ No newline at end of file diff --git a/Pages/Movies/Create.cshtml b/src/Pages/Movies/Create.cshtml similarity index 100% rename from Pages/Movies/Create.cshtml rename to src/Pages/Movies/Create.cshtml diff --git a/Pages/Movies/Create.cshtml.cs b/src/Pages/Movies/Create.cshtml.cs similarity index 100% rename from Pages/Movies/Create.cshtml.cs rename to src/Pages/Movies/Create.cshtml.cs diff --git a/Pages/Movies/Delete.cshtml b/src/Pages/Movies/Delete.cshtml similarity index 100% rename from Pages/Movies/Delete.cshtml rename to src/Pages/Movies/Delete.cshtml diff --git a/Pages/Movies/Delete.cshtml.cs b/src/Pages/Movies/Delete.cshtml.cs similarity index 100% rename from Pages/Movies/Delete.cshtml.cs rename to src/Pages/Movies/Delete.cshtml.cs diff --git a/Pages/Movies/Details.cshtml b/src/Pages/Movies/Details.cshtml similarity index 100% rename from Pages/Movies/Details.cshtml rename to src/Pages/Movies/Details.cshtml diff --git a/Pages/Movies/Details.cshtml.cs b/src/Pages/Movies/Details.cshtml.cs similarity index 100% rename from Pages/Movies/Details.cshtml.cs rename to src/Pages/Movies/Details.cshtml.cs diff --git a/src/Pages/Movies/Edit.cshtml b/src/Pages/Movies/Edit.cshtml new file mode 100644 index 0000000..16022d3 --- /dev/null +++ b/src/Pages/Movies/Edit.cshtml @@ -0,0 +1,56 @@ +@page +@model RazorPagesMovie.Pages.Movies.EditModel + +@{ + ViewData["Title"] = "Edit Movie"; +} + +

Edit Movie

+ +

Movie

+
+
+
+
+
+ + +
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+
+
+
+ + + +@section Scripts { + +} diff --git a/Pages/Movies/Edit.cshtml.cs b/src/Pages/Movies/Edit.cshtml.cs similarity index 72% rename from Pages/Movies/Edit.cshtml.cs rename to src/Pages/Movies/Edit.cshtml.cs index 5c9c77a..aa11069 100644 --- a/Pages/Movies/Edit.cshtml.cs +++ b/src/Pages/Movies/Edit.cshtml.cs @@ -13,15 +13,15 @@ namespace RazorPagesMovie.Pages.Movies { public class EditModel : PageModel { - private readonly RazorPagesMovie.Data.RazorPagesMovieContext _context; + private readonly RazorPagesMovieContext _context; - public EditModel(RazorPagesMovie.Data.RazorPagesMovieContext context) + public EditModel(RazorPagesMovieContext context) { _context = context; } [BindProperty] - public Movie Movie { get; set; } = default!; + public Movie? Movie { get; set; } public async Task OnGetAsync(int? id) { @@ -30,24 +30,23 @@ public async Task OnGetAsync(int? id) return NotFound(); } - var movie = await _context.Movie.FirstOrDefaultAsync(m => m.Id == id); - if (movie == null) + Movie = await _context.Movie.FirstOrDefaultAsync(m => m.Id == id); + + if (Movie == null) { return NotFound(); } - Movie = movie; return Page(); } - // To protect from overposting attacks, enable the specific properties you want to bind to. - // For more details, see https://aka.ms/RazorPagesCRUD. public async Task OnPostAsync() { - if (!ModelState.IsValid) + if (!ModelState.IsValid || Movie == null) { return Page(); } + var movieEntity = (Movie)Movie; _context.Attach(Movie).State = EntityState.Modified; try diff --git a/Pages/Movies/Index.cshtml b/src/Pages/Movies/Index.cshtml similarity index 74% rename from Pages/Movies/Index.cshtml rename to src/Pages/Movies/Index.cshtml index 86a2247..995db23 100644 --- a/Pages/Movies/Index.cshtml +++ b/src/Pages/Movies/Index.cshtml @@ -3,6 +3,7 @@ @{ ViewData["Title"] = "Movies"; + var userRole = HttpContext.Session.GetString("UserRole"); }
@@ -45,10 +46,19 @@

Genre: @item.Genre

Price: @item.Price.ToString("c")

Rating: @item.Rating

-
- Edit - Delete -
+ @if (userRole == "Admin") + { +
+ Edit + Delete +
+ } + else + { +
+ +
+ }
diff --git a/Pages/Movies/Index.cshtml.cs b/src/Pages/Movies/Index.cshtml.cs similarity index 99% rename from Pages/Movies/Index.cshtml.cs rename to src/Pages/Movies/Index.cshtml.cs index da09663..e320e64 100644 --- a/Pages/Movies/Index.cshtml.cs +++ b/src/Pages/Movies/Index.cshtml.cs @@ -47,7 +47,5 @@ orderby m.Genre Genres = new SelectList(await genreQuery.Distinct().ToListAsync()); Movie = await movies.ToListAsync(); } - - } } diff --git a/Pages/Privacy.cshtml b/src/Pages/Privacy.cshtml similarity index 100% rename from Pages/Privacy.cshtml rename to src/Pages/Privacy.cshtml diff --git a/Pages/Privacy.cshtml.cs b/src/Pages/Privacy.cshtml.cs similarity index 100% rename from Pages/Privacy.cshtml.cs rename to src/Pages/Privacy.cshtml.cs diff --git a/Pages/Shared/_Layout.cshtml b/src/Pages/Shared/_Layout.cshtml similarity index 53% rename from Pages/Shared/_Layout.cshtml rename to src/Pages/Shared/_Layout.cshtml index 92edce3..53568bd 100644 --- a/Pages/Shared/_Layout.cshtml +++ b/src/Pages/Shared/_Layout.cshtml @@ -6,46 +6,49 @@ @ViewData["Title"] - Movie - @* *@
-
-
- @RenderBody() -
+
+ @RenderBody()
- @*
-
- © 2024 - RazorPagesMovie - Privacy -
-
*@ - - - + + + - @RenderSection("Scripts", required: false) \ No newline at end of file diff --git a/Pages/Shared/_Layout.cshtml.css b/src/Pages/Shared/_Layout.cshtml.css similarity index 100% rename from Pages/Shared/_Layout.cshtml.css rename to src/Pages/Shared/_Layout.cshtml.css diff --git a/Pages/Shared/_ValidationScriptsPartial.cshtml b/src/Pages/Shared/_ValidationScriptsPartial.cshtml similarity index 100% rename from Pages/Shared/_ValidationScriptsPartial.cshtml rename to src/Pages/Shared/_ValidationScriptsPartial.cshtml diff --git a/Pages/_ViewImports.cshtml b/src/Pages/_ViewImports.cshtml similarity index 57% rename from Pages/_ViewImports.cshtml rename to src/Pages/_ViewImports.cshtml index 9d4de1f..6a787bb 100644 --- a/Pages/_ViewImports.cshtml +++ b/src/Pages/_ViewImports.cshtml @@ -1,3 +1,5 @@ @using RazorPagesMovie @namespace RazorPagesMovie.Pages @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@using System.ComponentModel.DataAnnotations +@using Microsoft.EntityFrameworkCore diff --git a/Pages/_ViewStart.cshtml b/src/Pages/_ViewStart.cshtml similarity index 100% rename from Pages/_ViewStart.cshtml rename to src/Pages/_ViewStart.cshtml diff --git a/src/Program.cs b/src/Program.cs new file mode 100644 index 0000000..2ebbeff --- /dev/null +++ b/src/Program.cs @@ -0,0 +1,154 @@ +#define DEFAULT // SQL server is default, SQL_Lite is other +#if DEFAULT +using Microsoft.EntityFrameworkCore; +using RazorPagesMovie.Data; +using RazorPagesMovie.Models; + +var builder = WebApplication.CreateBuilder(args); + +// Add logging +builder.Logging.ClearProviders(); +builder.Logging.AddConsole(); + +builder.Services.AddRazorPages(); +builder.Services.AddSession(options => +{ + options.IdleTimeout = TimeSpan.FromMinutes(30); + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; +}); +builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("RazorPagesMovieContext") ?? throw new InvalidOperationException("Connection string 'RazorPagesMovieContext' not found."))); + +var app = builder.Build(); + +// Update database initialization with error handling +using (var scope = app.Services.CreateScope()) +{ + var services = scope.ServiceProvider; + try + { + var logger = services.GetRequiredService>(); + var context = services.GetRequiredService(); + + logger.LogInformation("Starting database migration"); + context.Database.Migrate(); + + logger.LogInformation("Starting data seeding"); + SeedData.Initialize(services); + + logger.LogInformation("Database initialization completed"); + } + catch (Exception ex) + { + var logger = services.GetRequiredService>(); + logger.LogError(ex, "An error occurred while migrating/seeding the database."); + throw; + } +} + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error"); + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseStaticFiles(); +app.UseRouting(); +app.UseAuthorization(); +app.UseSession(); +app.Use(async (context, next) => +{ + var path = context.Request.Path; + var isAccountPage = path.StartsWithSegments("/Account"); + var isAuthenticated = context.Session.GetInt32("UserId").HasValue; + + if (!isAuthenticated && !isAccountPage && !path.StartsWithSegments("/Index")) + { + context.Response.Redirect("/Account/Login"); + return; + } + + await next(); +}); +app.MapRazorPages(); + +app.Run(); +#elif SQL_Lite +using Microsoft.EntityFrameworkCore; +using RazorPagesMovie.Data; +using RazorPagesMovie.Models; + +var builder = WebApplication.CreateBuilder(args); + +// Add logging +builder.Logging.ClearProviders(); +builder.Logging.AddConsole(); + +builder.Services.AddRazorPages(); +builder.Services.AddSession(options => +{ + options.IdleTimeout = TimeSpan.FromMinutes(30); + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; +}); +builder.Services.AddDbContext(options => + options.UseSqlite(builder.Configuration.GetConnectionString("RazorPagesMovieContext") ?? throw new InvalidOperationException("Connection string 'RazorPagesMovieContext' not found."))); + +var app = builder.Build(); + +// Update database initialization with error handling +using (var scope = app.Services.CreateScope()) +{ + var services = scope.ServiceProvider; + try + { + var logger = services.GetRequiredService>(); + var context = services.GetRequiredService(); + + logger.LogInformation("Starting database migration"); + context.Database.Migrate(); + + logger.LogInformation("Starting data seeding"); + SeedData.Initialize(services); + + logger.LogInformation("Database initialization completed"); + } + catch (Exception ex) + { + var logger = services.GetRequiredService>(); + logger.LogError(ex, "An error occurred while migrating/seeding the database."); + throw; + } +} + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error"); + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseStaticFiles(); +app.UseRouting(); +app.UseAuthorization(); +app.UseSession(); +app.Use(async (context, next) => +{ + var path = context.Request.Path; + var isAccountPage = path.StartsWithSegments("/Account"); + var isAuthenticated = context.Session.GetInt32("UserId").HasValue; + + if (!isAuthenticated && !isAccountPage && !path.StartsWithSegments("/Index")) + { + context.Response.Redirect("/Account/Login"); + return; + } + + await next(); +}); +app.MapRazorPages(); + +app.Run(); +#endif \ No newline at end of file diff --git a/Properties/launchSettings.json b/src/Properties/launchSettings.json similarity index 100% rename from Properties/launchSettings.json rename to src/Properties/launchSettings.json diff --git a/src/RazorPagesMovie.Tests/RazorPagesMovie.Tests.csproj b/src/RazorPagesMovie.Tests/RazorPagesMovie.Tests.csproj new file mode 100644 index 0000000..5947e8a --- /dev/null +++ b/src/RazorPagesMovie.Tests/RazorPagesMovie.Tests.csproj @@ -0,0 +1,11 @@ + + + + net5.0 + + + + + + + \ No newline at end of file diff --git a/src/RazorPagesMovie.csproj b/src/RazorPagesMovie.csproj new file mode 100644 index 0000000..554385a --- /dev/null +++ b/src/RazorPagesMovie.csproj @@ -0,0 +1,35 @@ + + + + net6.0 + enable + enable + false + false + false + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/RazorPagesMovie.sln b/src/RazorPagesMovie.sln new file mode 100644 index 0000000..d6ccc8a --- /dev/null +++ b/src/RazorPagesMovie.sln @@ -0,0 +1,33 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RazorPagesMovie", "RazorPagesMovie.csproj", "{A964C2FC-1950-4A0B-B314-2FEBCC9C2315}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RazorPagesMovie.UITests", "RazorPagesMovie.UITests/RazorPagesMovie.UITests.csproj", "{52C6A6B3-6073-4057-B293-B7FD65DF5C70}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RazorPagesMovie.Tests", "RazorPagesMovie.Tests/RazorPagesMovie.Tests.csproj", "{4E568B06-2DAA-4078-85E3-B30BFEBAA389}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A964C2FC-1950-4A0B-B314-2FEBCC9C2315}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A964C2FC-1950-4A0B-B314-2FEBCC9C2315}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A964C2FC-1950-4A0B-B314-2FEBCC9C2315}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A964C2FC-1950-4A0B-B314-2FEBCC9C2315}.Release|Any CPU.Build.0 = Release|Any CPU + {52C6A6B3-6073-4057-B293-B7FD65DF5C70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {52C6A6B3-6073-4057-B293-B7FD65DF5C70}.Debug|Any CPU.Build.0 = Debug|Any CPU + {52C6A6B3-6073-4057-B293-B7FD65DF5C70}.Release|Any CPU.ActiveCfg = Release|Any CPU + {52C6A6B3-6073-4057-B293-B7FD65DF5C70}.Release|Any CPU.Build.0 = Release|Any CPU + {4E568B06-2DAA-4078-85E3-B30BFEBAA389}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4E568B06-2DAA-4078-85E3-B30BFEBAA389}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4E568B06-2DAA-4078-85E3-B30BFEBAA389}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4E568B06-2DAA-4078-85E3-B30BFEBAA389}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/global.json b/src/global.json similarity index 100% rename from global.json rename to src/global.json diff --git a/src/keys/key-99dbec8e-6fb9-4a1d-b229-f6b26c27e4bd.xml b/src/keys/key-99dbec8e-6fb9-4a1d-b229-f6b26c27e4bd.xml new file mode 100644 index 0000000..8e235bf --- /dev/null +++ b/src/keys/key-99dbec8e-6fb9-4a1d-b229-f6b26c27e4bd.xml @@ -0,0 +1,16 @@ + + + 2024-12-01T14:26:58.613589Z + 2024-12-01T14:26:58.262647Z + 2025-03-01T14:26:58.262647Z + + + + + + + SCs4NHFwz78DLROzICwPoR3V884H8/HXpGMNGK/zhiSHJ+3hP3LBIFJtv8ZfkUJ9iKrvOSmKFzk8EDCfAY43eg== + + + + \ No newline at end of file diff --git a/wwwroot/css/site.css b/src/wwwroot/css/site.css similarity index 100% rename from wwwroot/css/site.css rename to src/wwwroot/css/site.css diff --git a/wwwroot/css/site.css.br b/src/wwwroot/css/site.css.br similarity index 100% rename from wwwroot/css/site.css.br rename to src/wwwroot/css/site.css.br diff --git a/wwwroot/favicon.ico b/src/wwwroot/favicon.ico similarity index 100% rename from wwwroot/favicon.ico rename to src/wwwroot/favicon.ico diff --git a/wwwroot/favicon.ico.br b/src/wwwroot/favicon.ico.br similarity index 100% rename from wwwroot/favicon.ico.br rename to src/wwwroot/favicon.ico.br diff --git a/wwwroot/js/site.js b/src/wwwroot/js/site.js similarity index 99% rename from wwwroot/js/site.js rename to src/wwwroot/js/site.js index 2098c8d..0d03ff2 100644 --- a/wwwroot/js/site.js +++ b/src/wwwroot/js/site.js @@ -50,7 +50,7 @@ backToTop.classList.remove('show'); } }); - +/* // Add modal popup with less intrusive behavior if (!localStorage.getItem('hideWelcomePopup')) { const modal = document.createElement('div'); @@ -74,7 +74,7 @@ }); setTimeout(() => modal.style.display = 'flex', 5000); // Show after 5 seconds } - +*/ // Add scroll progress bar const scrollProgress = document.createElement('div'); scrollProgress.id = 'scrollProgress'; diff --git a/wwwroot/js/site.js.br b/src/wwwroot/js/site.js.br similarity index 100% rename from wwwroot/js/site.js.br rename to src/wwwroot/js/site.js.br diff --git a/wwwroot/lib/bootstrap/LICENSE b/src/wwwroot/lib/bootstrap/LICENSE similarity index 100% rename from wwwroot/lib/bootstrap/LICENSE rename to src/wwwroot/lib/bootstrap/LICENSE diff --git a/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css b/src/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css similarity index 100% rename from wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css rename to src/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css diff --git a/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css b/src/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css similarity index 100% rename from wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css rename to src/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css diff --git a/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css b/src/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css similarity index 100% rename from wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css rename to src/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css diff --git a/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css b/src/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css similarity index 100% rename from wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css rename to src/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css diff --git a/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css b/src/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css similarity index 100% rename from wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css rename to src/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css diff --git a/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css b/src/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css similarity index 100% rename from wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css rename to src/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css diff --git a/wwwroot/lib/bootstrap/dist/css/bootstrap.css b/src/wwwroot/lib/bootstrap/dist/css/bootstrap.css similarity index 100% rename from wwwroot/lib/bootstrap/dist/css/bootstrap.css rename to src/wwwroot/lib/bootstrap/dist/css/bootstrap.css diff --git a/wwwroot/lib/bootstrap/dist/css/bootstrap.css.br b/src/wwwroot/lib/bootstrap/dist/css/bootstrap.css.br similarity index 100% rename from wwwroot/lib/bootstrap/dist/css/bootstrap.css.br rename to src/wwwroot/lib/bootstrap/dist/css/bootstrap.css.br diff --git a/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css b/src/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css similarity index 100% rename from wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css rename to src/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css diff --git a/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js b/src/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js similarity index 100% rename from wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js rename to src/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js diff --git a/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.br b/src/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.br similarity index 100% rename from wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.br rename to src/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.br diff --git a/src/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js b/src/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js new file mode 100644 index 0000000..cc0a255 --- /dev/null +++ b/src/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js @@ -0,0 +1,7 @@ +/*! + * Bootstrap v5.1.3 (https://getbootstrap.com/) + * Copyright 2011-2021 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t="transitionend",e=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return e},i=t=>{const i=e(t);return i&&document.querySelector(i)?i:null},n=t=>{const i=e(t);return i?document.querySelector(i):null},s=e=>{e.dispatchEvent(new Event(t))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(t):null,a=(t,e,i)=>{Object.keys(i).forEach((n=>{const s=i[n],r=e[n],a=r&&o(r)?"element":null==(l=r)?`${l}`:{}.toString.call(l).match(/\s([a-z]+)/i)[1].toLowerCase();var l;if(!new RegExp(s).test(a))throw new TypeError(`${t.toUpperCase()}: Option "${n}" provided type "${a}" but expected type "${s}".`)}))},l=t=>!(!o(t)||0===t.getClientRects().length)&&"visible"===getComputedStyle(t).getPropertyValue("visibility"),c=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),h=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?h(t.parentNode):null},d=()=>{},u=t=>{t.offsetHeight},f=()=>{const{jQuery:t}=window;return t&&!document.body.hasAttribute("data-bs-no-jquery")?t:null},p=[],m=()=>"rtl"===document.documentElement.dir,g=t=>{var e;e=()=>{const e=f();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(p.length||document.addEventListener("DOMContentLoaded",(()=>{p.forEach((t=>t()))})),p.push(e)):e()},_=t=>{"function"==typeof t&&t()},b=(e,i,n=!0)=>{if(!n)return void _(e);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(i)+5;let r=!1;const a=({target:n})=>{n===i&&(r=!0,i.removeEventListener(t,a),_(e))};i.addEventListener(t,a),setTimeout((()=>{r||s(i)}),o)},v=(t,e,i,n)=>{let s=t.indexOf(e);if(-1===s)return t[!i&&n?t.length-1:0];const o=t.length;return s+=i?1:-1,n&&(s=(s+o)%o),t[Math.max(0,Math.min(s,o-1))]},y=/[^.]*(?=\..*)\.|.*/,w=/\..*/,E=/::\d+$/,A={};let T=1;const O={mouseenter:"mouseover",mouseleave:"mouseout"},C=/^(mouseenter|mouseleave)/i,k=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function L(t,e){return e&&`${e}::${T++}`||t.uidEvent||T++}function x(t){const e=L(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function D(t,e,i=null){const n=Object.keys(t);for(let s=0,o=n.length;sfunction(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};n?n=t(n):i=t(i)}const[o,r,a]=S(e,i,n),l=x(t),c=l[a]||(l[a]={}),h=D(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=L(r,e.replace(y,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(let a=o.length;a--;)if(o[a]===r)return s.delegateTarget=r,n.oneOff&&j.off(t,s.type,e,i),i.apply(r,[s]);return null}}(t,i,n):function(t,e){return function i(n){return n.delegateTarget=t,i.oneOff&&j.off(t,n.type,e),e.apply(t,[n])}}(t,i);u.delegationSelector=o?i:null,u.originalHandler=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function I(t,e,i,n,s){const o=D(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function P(t){return t=t.replace(w,""),O[t]||t}const j={on(t,e,i,n){N(t,e,i,n,!1)},one(t,e,i,n){N(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=S(e,i,n),a=r!==e,l=x(t),c=e.startsWith(".");if(void 0!==o){if(!l||!l[r])return;return void I(t,l,r,o,s?i:null)}c&&Object.keys(l).forEach((i=>{!function(t,e,i,n){const s=e[i]||{};Object.keys(s).forEach((o=>{if(o.includes(n)){const n=s[o];I(t,e,i,n.originalHandler,n.delegationSelector)}}))}(t,l,i,e.slice(1))}));const h=l[r]||{};Object.keys(h).forEach((i=>{const n=i.replace(E,"");if(!a||e.includes(n)){const e=h[i];I(t,l,r,e.originalHandler,e.delegationSelector)}}))},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=f(),s=P(e),o=e!==s,r=k.has(s);let a,l=!0,c=!0,h=!1,d=null;return o&&n&&(a=n.Event(e,i),n(t).trigger(a),l=!a.isPropagationStopped(),c=!a.isImmediatePropagationStopped(),h=a.isDefaultPrevented()),r?(d=document.createEvent("HTMLEvents"),d.initEvent(s,l,!0)):d=new CustomEvent(e,{bubbles:l,cancelable:!0}),void 0!==i&&Object.keys(i).forEach((t=>{Object.defineProperty(d,t,{get:()=>i[t]})})),h&&d.preventDefault(),c&&t.dispatchEvent(d),d.defaultPrevented&&void 0!==a&&a.preventDefault(),d}},M=new Map,H={set(t,e,i){M.has(t)||M.set(t,new Map);const n=M.get(t);n.has(e)||0===n.size?n.set(e,i):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(n.keys())[0]}.`)},get:(t,e)=>M.has(t)&&M.get(t).get(e)||null,remove(t,e){if(!M.has(t))return;const i=M.get(t);i.delete(e),0===i.size&&M.delete(t)}};class B{constructor(t){(t=r(t))&&(this._element=t,H.set(this._element,this.constructor.DATA_KEY,this))}dispose(){H.remove(this._element,this.constructor.DATA_KEY),j.off(this._element,this.constructor.EVENT_KEY),Object.getOwnPropertyNames(this).forEach((t=>{this[t]=null}))}_queueCallback(t,e,i=!0){b(t,e,i)}static getInstance(t){return H.get(r(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.1.3"}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}}const R=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,s=t.NAME;j.on(document,i,`[data-bs-dismiss="${s}"]`,(function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),c(this))return;const o=n(this)||this.closest(`.${s}`);t.getOrCreateInstance(o)[e]()}))};class W extends B{static get NAME(){return"alert"}close(){if(j.trigger(this._element,"close.bs.alert").defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,t)}_destroyElement(){this._element.remove(),j.trigger(this._element,"closed.bs.alert"),this.dispose()}static jQueryInterface(t){return this.each((function(){const e=W.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}R(W,"close"),g(W);const $='[data-bs-toggle="button"]';class z extends B{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=z.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}function q(t){return"true"===t||"false"!==t&&(t===Number(t).toString()?Number(t):""===t||"null"===t?null:t)}function F(t){return t.replace(/[A-Z]/g,(t=>`-${t.toLowerCase()}`))}j.on(document,"click.bs.button.data-api",$,(t=>{t.preventDefault();const e=t.target.closest($);z.getOrCreateInstance(e).toggle()})),g(z);const U={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${F(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${F(e)}`)},getDataAttributes(t){if(!t)return{};const e={};return Object.keys(t.dataset).filter((t=>t.startsWith("bs"))).forEach((i=>{let n=i.replace(/^bs/,"");n=n.charAt(0).toLowerCase()+n.slice(1,n.length),e[n]=q(t.dataset[i])})),e},getDataAttribute:(t,e)=>q(t.getAttribute(`data-bs-${F(e)}`)),offset(t){const e=t.getBoundingClientRect();return{top:e.top+window.pageYOffset,left:e.left+window.pageXOffset}},position:t=>({top:t.offsetTop,left:t.offsetLeft})},V={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const i=[];let n=t.parentNode;for(;n&&n.nodeType===Node.ELEMENT_NODE&&3!==n.nodeType;)n.matches(e)&&i.push(n),n=n.parentNode;return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>`${t}:not([tabindex^="-"])`)).join(", ");return this.find(e,t).filter((t=>!c(t)&&l(t)))}},K="carousel",X={interval:5e3,keyboard:!0,slide:!1,pause:"hover",wrap:!0,touch:!0},Y={interval:"(number|boolean)",keyboard:"boolean",slide:"(boolean|string)",pause:"(string|boolean)",wrap:"boolean",touch:"boolean"},Q="next",G="prev",Z="left",J="right",tt={ArrowLeft:J,ArrowRight:Z},et="slid.bs.carousel",it="active",nt=".active.carousel-item";class st extends B{constructor(t,e){super(t),this._items=null,this._interval=null,this._activeElement=null,this._isPaused=!1,this._isSliding=!1,this.touchTimeout=null,this.touchStartX=0,this.touchDeltaX=0,this._config=this._getConfig(e),this._indicatorsElement=V.findOne(".carousel-indicators",this._element),this._touchSupported="ontouchstart"in document.documentElement||navigator.maxTouchPoints>0,this._pointerEvent=Boolean(window.PointerEvent),this._addEventListeners()}static get Default(){return X}static get NAME(){return K}next(){this._slide(Q)}nextWhenVisible(){!document.hidden&&l(this._element)&&this.next()}prev(){this._slide(G)}pause(t){t||(this._isPaused=!0),V.findOne(".carousel-item-next, .carousel-item-prev",this._element)&&(s(this._element),this.cycle(!0)),clearInterval(this._interval),this._interval=null}cycle(t){t||(this._isPaused=!1),this._interval&&(clearInterval(this._interval),this._interval=null),this._config&&this._config.interval&&!this._isPaused&&(this._updateInterval(),this._interval=setInterval((document.visibilityState?this.nextWhenVisible:this.next).bind(this),this._config.interval))}to(t){this._activeElement=V.findOne(nt,this._element);const e=this._getItemIndex(this._activeElement);if(t>this._items.length-1||t<0)return;if(this._isSliding)return void j.one(this._element,et,(()=>this.to(t)));if(e===t)return this.pause(),void this.cycle();const i=t>e?Q:G;this._slide(i,this._items[t])}_getConfig(t){return t={...X,...U.getDataAttributes(this._element),..."object"==typeof t?t:{}},a(K,t,Y),t}_handleSwipe(){const t=Math.abs(this.touchDeltaX);if(t<=40)return;const e=t/this.touchDeltaX;this.touchDeltaX=0,e&&this._slide(e>0?J:Z)}_addEventListeners(){this._config.keyboard&&j.on(this._element,"keydown.bs.carousel",(t=>this._keydown(t))),"hover"===this._config.pause&&(j.on(this._element,"mouseenter.bs.carousel",(t=>this.pause(t))),j.on(this._element,"mouseleave.bs.carousel",(t=>this.cycle(t)))),this._config.touch&&this._touchSupported&&this._addTouchEventListeners()}_addTouchEventListeners(){const t=t=>this._pointerEvent&&("pen"===t.pointerType||"touch"===t.pointerType),e=e=>{t(e)?this.touchStartX=e.clientX:this._pointerEvent||(this.touchStartX=e.touches[0].clientX)},i=t=>{this.touchDeltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this.touchStartX},n=e=>{t(e)&&(this.touchDeltaX=e.clientX-this.touchStartX),this._handleSwipe(),"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((t=>this.cycle(t)),500+this._config.interval))};V.find(".carousel-item img",this._element).forEach((t=>{j.on(t,"dragstart.bs.carousel",(t=>t.preventDefault()))})),this._pointerEvent?(j.on(this._element,"pointerdown.bs.carousel",(t=>e(t))),j.on(this._element,"pointerup.bs.carousel",(t=>n(t))),this._element.classList.add("pointer-event")):(j.on(this._element,"touchstart.bs.carousel",(t=>e(t))),j.on(this._element,"touchmove.bs.carousel",(t=>i(t))),j.on(this._element,"touchend.bs.carousel",(t=>n(t))))}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=tt[t.key];e&&(t.preventDefault(),this._slide(e))}_getItemIndex(t){return this._items=t&&t.parentNode?V.find(".carousel-item",t.parentNode):[],this._items.indexOf(t)}_getItemByOrder(t,e){const i=t===Q;return v(this._items,e,i,this._config.wrap)}_triggerSlideEvent(t,e){const i=this._getItemIndex(t),n=this._getItemIndex(V.findOne(nt,this._element));return j.trigger(this._element,"slide.bs.carousel",{relatedTarget:t,direction:e,from:n,to:i})}_setActiveIndicatorElement(t){if(this._indicatorsElement){const e=V.findOne(".active",this._indicatorsElement);e.classList.remove(it),e.removeAttribute("aria-current");const i=V.find("[data-bs-target]",this._indicatorsElement);for(let e=0;e{j.trigger(this._element,et,{relatedTarget:o,direction:d,from:s,to:r})};if(this._element.classList.contains("slide")){o.classList.add(h),u(o),n.classList.add(c),o.classList.add(c);const t=()=>{o.classList.remove(c,h),o.classList.add(it),n.classList.remove(it,h,c),this._isSliding=!1,setTimeout(f,0)};this._queueCallback(t,n,!0)}else n.classList.remove(it),o.classList.add(it),this._isSliding=!1,f();a&&this.cycle()}_directionToOrder(t){return[J,Z].includes(t)?m()?t===Z?G:Q:t===Z?Q:G:t}_orderToDirection(t){return[Q,G].includes(t)?m()?t===G?Z:J:t===G?J:Z:t}static carouselInterface(t,e){const i=st.getOrCreateInstance(t,e);let{_config:n}=i;"object"==typeof e&&(n={...n,...e});const s="string"==typeof e?e:n.slide;if("number"==typeof e)i.to(e);else if("string"==typeof s){if(void 0===i[s])throw new TypeError(`No method named "${s}"`);i[s]()}else n.interval&&n.ride&&(i.pause(),i.cycle())}static jQueryInterface(t){return this.each((function(){st.carouselInterface(this,t)}))}static dataApiClickHandler(t){const e=n(this);if(!e||!e.classList.contains("carousel"))return;const i={...U.getDataAttributes(e),...U.getDataAttributes(this)},s=this.getAttribute("data-bs-slide-to");s&&(i.interval=!1),st.carouselInterface(e,i),s&&st.getInstance(e).to(s),t.preventDefault()}}j.on(document,"click.bs.carousel.data-api","[data-bs-slide], [data-bs-slide-to]",st.dataApiClickHandler),j.on(window,"load.bs.carousel.data-api",(()=>{const t=V.find('[data-bs-ride="carousel"]');for(let e=0,i=t.length;et===this._element));null!==s&&o.length&&(this._selector=s,this._triggerArray.push(e))}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return rt}static get NAME(){return ot}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t,e=[];if(this._config.parent){const t=V.find(ut,this._config.parent);e=V.find(".collapse.show, .collapse.collapsing",this._config.parent).filter((e=>!t.includes(e)))}const i=V.findOne(this._selector);if(e.length){const n=e.find((t=>i!==t));if(t=n?pt.getInstance(n):null,t&&t._isTransitioning)return}if(j.trigger(this._element,"show.bs.collapse").defaultPrevented)return;e.forEach((e=>{i!==e&&pt.getOrCreateInstance(e,{toggle:!1}).hide(),t||H.set(e,"bs.collapse",null)}));const n=this._getDimension();this._element.classList.remove(ct),this._element.classList.add(ht),this._element.style[n]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const s=`scroll${n[0].toUpperCase()+n.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(ht),this._element.classList.add(ct,lt),this._element.style[n]="",j.trigger(this._element,"shown.bs.collapse")}),this._element,!0),this._element.style[n]=`${this._element[s]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(j.trigger(this._element,"hide.bs.collapse").defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,u(this._element),this._element.classList.add(ht),this._element.classList.remove(ct,lt);const e=this._triggerArray.length;for(let t=0;t{this._isTransitioning=!1,this._element.classList.remove(ht),this._element.classList.add(ct),j.trigger(this._element,"hidden.bs.collapse")}),this._element,!0)}_isShown(t=this._element){return t.classList.contains(lt)}_getConfig(t){return(t={...rt,...U.getDataAttributes(this._element),...t}).toggle=Boolean(t.toggle),t.parent=r(t.parent),a(ot,t,at),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=V.find(ut,this._config.parent);V.find(ft,this._config.parent).filter((e=>!t.includes(e))).forEach((t=>{const e=n(t);e&&this._addAriaAndCollapsedClass([t],this._isShown(e))}))}_addAriaAndCollapsedClass(t,e){t.length&&t.forEach((t=>{e?t.classList.remove(dt):t.classList.add(dt),t.setAttribute("aria-expanded",e)}))}static jQueryInterface(t){return this.each((function(){const e={};"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1);const i=pt.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}}))}}j.on(document,"click.bs.collapse.data-api",ft,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();const e=i(this);V.find(e).forEach((t=>{pt.getOrCreateInstance(t,{toggle:!1}).toggle()}))})),g(pt);var mt="top",gt="bottom",_t="right",bt="left",vt="auto",yt=[mt,gt,_t,bt],wt="start",Et="end",At="clippingParents",Tt="viewport",Ot="popper",Ct="reference",kt=yt.reduce((function(t,e){return t.concat([e+"-"+wt,e+"-"+Et])}),[]),Lt=[].concat(yt,[vt]).reduce((function(t,e){return t.concat([e,e+"-"+wt,e+"-"+Et])}),[]),xt="beforeRead",Dt="read",St="afterRead",Nt="beforeMain",It="main",Pt="afterMain",jt="beforeWrite",Mt="write",Ht="afterWrite",Bt=[xt,Dt,St,Nt,It,Pt,jt,Mt,Ht];function Rt(t){return t?(t.nodeName||"").toLowerCase():null}function Wt(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function $t(t){return t instanceof Wt(t).Element||t instanceof Element}function zt(t){return t instanceof Wt(t).HTMLElement||t instanceof HTMLElement}function qt(t){return"undefined"!=typeof ShadowRoot&&(t instanceof Wt(t).ShadowRoot||t instanceof ShadowRoot)}const Ft={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];zt(s)&&Rt(s)&&(Object.assign(s.style,i),Object.keys(n).forEach((function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach((function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce((function(t,e){return t[e]="",t}),{});zt(n)&&Rt(n)&&(Object.assign(n.style,o),Object.keys(s).forEach((function(t){n.removeAttribute(t)})))}))}},requires:["computeStyles"]};function Ut(t){return t.split("-")[0]}function Vt(t,e){var i=t.getBoundingClientRect();return{width:i.width/1,height:i.height/1,top:i.top/1,right:i.right/1,bottom:i.bottom/1,left:i.left/1,x:i.left/1,y:i.top/1}}function Kt(t){var e=Vt(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function Xt(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&qt(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function Yt(t){return Wt(t).getComputedStyle(t)}function Qt(t){return["table","td","th"].indexOf(Rt(t))>=0}function Gt(t){return(($t(t)?t.ownerDocument:t.document)||window.document).documentElement}function Zt(t){return"html"===Rt(t)?t:t.assignedSlot||t.parentNode||(qt(t)?t.host:null)||Gt(t)}function Jt(t){return zt(t)&&"fixed"!==Yt(t).position?t.offsetParent:null}function te(t){for(var e=Wt(t),i=Jt(t);i&&Qt(i)&&"static"===Yt(i).position;)i=Jt(i);return i&&("html"===Rt(i)||"body"===Rt(i)&&"static"===Yt(i).position)?e:i||function(t){var e=-1!==navigator.userAgent.toLowerCase().indexOf("firefox");if(-1!==navigator.userAgent.indexOf("Trident")&&zt(t)&&"fixed"===Yt(t).position)return null;for(var i=Zt(t);zt(i)&&["html","body"].indexOf(Rt(i))<0;){var n=Yt(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function ee(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}var ie=Math.max,ne=Math.min,se=Math.round;function oe(t,e,i){return ie(t,ne(e,i))}function re(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function ae(t,e){return e.reduce((function(e,i){return e[i]=t,e}),{})}const le={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,n=t.name,s=t.options,o=i.elements.arrow,r=i.modifiersData.popperOffsets,a=Ut(i.placement),l=ee(a),c=[bt,_t].indexOf(a)>=0?"height":"width";if(o&&r){var h=function(t,e){return re("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:ae(t,yt))}(s.padding,i),d=Kt(o),u="y"===l?mt:bt,f="y"===l?gt:_t,p=i.rects.reference[c]+i.rects.reference[l]-r[l]-i.rects.popper[c],m=r[l]-i.rects.reference[l],g=te(o),_=g?"y"===l?g.clientHeight||0:g.clientWidth||0:0,b=p/2-m/2,v=h[u],y=_-d[c]-h[f],w=_/2-d[c]/2+b,E=oe(v,w,y),A=l;i.modifiersData[n]=((e={})[A]=E,e.centerOffset=E-w,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&Xt(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function ce(t){return t.split("-")[1]}var he={top:"auto",right:"auto",bottom:"auto",left:"auto"};function de(t){var e,i=t.popper,n=t.popperRect,s=t.placement,o=t.variation,r=t.offsets,a=t.position,l=t.gpuAcceleration,c=t.adaptive,h=t.roundOffsets,d=!0===h?function(t){var e=t.x,i=t.y,n=window.devicePixelRatio||1;return{x:se(se(e*n)/n)||0,y:se(se(i*n)/n)||0}}(r):"function"==typeof h?h(r):r,u=d.x,f=void 0===u?0:u,p=d.y,m=void 0===p?0:p,g=r.hasOwnProperty("x"),_=r.hasOwnProperty("y"),b=bt,v=mt,y=window;if(c){var w=te(i),E="clientHeight",A="clientWidth";w===Wt(i)&&"static"!==Yt(w=Gt(i)).position&&"absolute"===a&&(E="scrollHeight",A="scrollWidth"),w=w,s!==mt&&(s!==bt&&s!==_t||o!==Et)||(v=gt,m-=w[E]-n.height,m*=l?1:-1),s!==bt&&(s!==mt&&s!==gt||o!==Et)||(b=_t,f-=w[A]-n.width,f*=l?1:-1)}var T,O=Object.assign({position:a},c&&he);return l?Object.assign({},O,((T={})[v]=_?"0":"",T[b]=g?"0":"",T.transform=(y.devicePixelRatio||1)<=1?"translate("+f+"px, "+m+"px)":"translate3d("+f+"px, "+m+"px, 0)",T)):Object.assign({},O,((e={})[v]=_?m+"px":"",e[b]=g?f+"px":"",e.transform="",e))}const ue={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:Ut(e.placement),variation:ce(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,de(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,de(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}};var fe={passive:!0};const pe={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=Wt(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach((function(t){t.addEventListener("scroll",i.update,fe)})),a&&l.addEventListener("resize",i.update,fe),function(){o&&c.forEach((function(t){t.removeEventListener("scroll",i.update,fe)})),a&&l.removeEventListener("resize",i.update,fe)}},data:{}};var me={left:"right",right:"left",bottom:"top",top:"bottom"};function ge(t){return t.replace(/left|right|bottom|top/g,(function(t){return me[t]}))}var _e={start:"end",end:"start"};function be(t){return t.replace(/start|end/g,(function(t){return _e[t]}))}function ve(t){var e=Wt(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function ye(t){return Vt(Gt(t)).left+ve(t).scrollLeft}function we(t){var e=Yt(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function Ee(t){return["html","body","#document"].indexOf(Rt(t))>=0?t.ownerDocument.body:zt(t)&&we(t)?t:Ee(Zt(t))}function Ae(t,e){var i;void 0===e&&(e=[]);var n=Ee(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=Wt(n),r=s?[o].concat(o.visualViewport||[],we(n)?n:[]):n,a=e.concat(r);return s?a:a.concat(Ae(Zt(r)))}function Te(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function Oe(t,e){return e===Tt?Te(function(t){var e=Wt(t),i=Gt(t),n=e.visualViewport,s=i.clientWidth,o=i.clientHeight,r=0,a=0;return n&&(s=n.width,o=n.height,/^((?!chrome|android).)*safari/i.test(navigator.userAgent)||(r=n.offsetLeft,a=n.offsetTop)),{width:s,height:o,x:r+ye(t),y:a}}(t)):zt(e)?function(t){var e=Vt(t);return e.top=e.top+t.clientTop,e.left=e.left+t.clientLeft,e.bottom=e.top+t.clientHeight,e.right=e.left+t.clientWidth,e.width=t.clientWidth,e.height=t.clientHeight,e.x=e.left,e.y=e.top,e}(e):Te(function(t){var e,i=Gt(t),n=ve(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=ie(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=ie(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+ye(t),l=-n.scrollTop;return"rtl"===Yt(s||i).direction&&(a+=ie(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}(Gt(t)))}function Ce(t){var e,i=t.reference,n=t.element,s=t.placement,o=s?Ut(s):null,r=s?ce(s):null,a=i.x+i.width/2-n.width/2,l=i.y+i.height/2-n.height/2;switch(o){case mt:e={x:a,y:i.y-n.height};break;case gt:e={x:a,y:i.y+i.height};break;case _t:e={x:i.x+i.width,y:l};break;case bt:e={x:i.x-n.width,y:l};break;default:e={x:i.x,y:i.y}}var c=o?ee(o):null;if(null!=c){var h="y"===c?"height":"width";switch(r){case wt:e[c]=e[c]-(i[h]/2-n[h]/2);break;case Et:e[c]=e[c]+(i[h]/2-n[h]/2)}}return e}function ke(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=void 0===n?t.placement:n,o=i.boundary,r=void 0===o?At:o,a=i.rootBoundary,l=void 0===a?Tt:a,c=i.elementContext,h=void 0===c?Ot:c,d=i.altBoundary,u=void 0!==d&&d,f=i.padding,p=void 0===f?0:f,m=re("number"!=typeof p?p:ae(p,yt)),g=h===Ot?Ct:Ot,_=t.rects.popper,b=t.elements[u?g:h],v=function(t,e,i){var n="clippingParents"===e?function(t){var e=Ae(Zt(t)),i=["absolute","fixed"].indexOf(Yt(t).position)>=0&&zt(t)?te(t):t;return $t(i)?e.filter((function(t){return $t(t)&&Xt(t,i)&&"body"!==Rt(t)})):[]}(t):[].concat(e),s=[].concat(n,[i]),o=s[0],r=s.reduce((function(e,i){var n=Oe(t,i);return e.top=ie(n.top,e.top),e.right=ne(n.right,e.right),e.bottom=ne(n.bottom,e.bottom),e.left=ie(n.left,e.left),e}),Oe(t,o));return r.width=r.right-r.left,r.height=r.bottom-r.top,r.x=r.left,r.y=r.top,r}($t(b)?b:b.contextElement||Gt(t.elements.popper),r,l),y=Vt(t.elements.reference),w=Ce({reference:y,element:_,strategy:"absolute",placement:s}),E=Te(Object.assign({},_,w)),A=h===Ot?E:y,T={top:v.top-A.top+m.top,bottom:A.bottom-v.bottom+m.bottom,left:v.left-A.left+m.left,right:A.right-v.right+m.right},O=t.modifiersData.offset;if(h===Ot&&O){var C=O[s];Object.keys(T).forEach((function(t){var e=[_t,gt].indexOf(t)>=0?1:-1,i=[mt,gt].indexOf(t)>=0?"y":"x";T[t]+=C[i]*e}))}return T}function Le(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,l=i.allowedAutoPlacements,c=void 0===l?Lt:l,h=ce(n),d=h?a?kt:kt.filter((function(t){return ce(t)===h})):yt,u=d.filter((function(t){return c.indexOf(t)>=0}));0===u.length&&(u=d);var f=u.reduce((function(e,i){return e[i]=ke(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[Ut(i)],e}),{});return Object.keys(f).sort((function(t,e){return f[t]-f[e]}))}const xe={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name;if(!e.modifiersData[n]._skip){for(var s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0===r||r,l=i.fallbackPlacements,c=i.padding,h=i.boundary,d=i.rootBoundary,u=i.altBoundary,f=i.flipVariations,p=void 0===f||f,m=i.allowedAutoPlacements,g=e.options.placement,_=Ut(g),b=l||(_!==g&&p?function(t){if(Ut(t)===vt)return[];var e=ge(t);return[be(t),e,be(e)]}(g):[ge(g)]),v=[g].concat(b).reduce((function(t,i){return t.concat(Ut(i)===vt?Le(e,{placement:i,boundary:h,rootBoundary:d,padding:c,flipVariations:p,allowedAutoPlacements:m}):i)}),[]),y=e.rects.reference,w=e.rects.popper,E=new Map,A=!0,T=v[0],O=0;O=0,D=x?"width":"height",S=ke(e,{placement:C,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),N=x?L?_t:bt:L?gt:mt;y[D]>w[D]&&(N=ge(N));var I=ge(N),P=[];if(o&&P.push(S[k]<=0),a&&P.push(S[N]<=0,S[I]<=0),P.every((function(t){return t}))){T=C,A=!1;break}E.set(C,P)}if(A)for(var j=function(t){var e=v.find((function(e){var i=E.get(e);if(i)return i.slice(0,t).every((function(t){return t}))}));if(e)return T=e,"break"},M=p?3:1;M>0&&"break"!==j(M);M--);e.placement!==T&&(e.modifiersData[n]._skip=!0,e.placement=T,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function De(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function Se(t){return[mt,_t,gt,bt].some((function(e){return t[e]>=0}))}const Ne={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=ke(e,{elementContext:"reference"}),a=ke(e,{altBoundary:!0}),l=De(r,n),c=De(a,s,o),h=Se(l),d=Se(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":h,"data-popper-escaped":d})}},Ie={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.offset,o=void 0===s?[0,0]:s,r=Lt.reduce((function(t,i){return t[i]=function(t,e,i){var n=Ut(t),s=[bt,mt].indexOf(n)>=0?-1:1,o="function"==typeof i?i(Object.assign({},e,{placement:t})):i,r=o[0],a=o[1];return r=r||0,a=(a||0)*s,[bt,_t].indexOf(n)>=0?{x:a,y:r}:{x:r,y:a}}(i,e.rects,o),t}),{}),a=r[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[n]=r}},Pe={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=Ce({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}},je={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0!==r&&r,l=i.boundary,c=i.rootBoundary,h=i.altBoundary,d=i.padding,u=i.tether,f=void 0===u||u,p=i.tetherOffset,m=void 0===p?0:p,g=ke(e,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),_=Ut(e.placement),b=ce(e.placement),v=!b,y=ee(_),w="x"===y?"y":"x",E=e.modifiersData.popperOffsets,A=e.rects.reference,T=e.rects.popper,O="function"==typeof m?m(Object.assign({},e.rects,{placement:e.placement})):m,C={x:0,y:0};if(E){if(o||a){var k="y"===y?mt:bt,L="y"===y?gt:_t,x="y"===y?"height":"width",D=E[y],S=E[y]+g[k],N=E[y]-g[L],I=f?-T[x]/2:0,P=b===wt?A[x]:T[x],j=b===wt?-T[x]:-A[x],M=e.elements.arrow,H=f&&M?Kt(M):{width:0,height:0},B=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},R=B[k],W=B[L],$=oe(0,A[x],H[x]),z=v?A[x]/2-I-$-R-O:P-$-R-O,q=v?-A[x]/2+I+$+W+O:j+$+W+O,F=e.elements.arrow&&te(e.elements.arrow),U=F?"y"===y?F.clientTop||0:F.clientLeft||0:0,V=e.modifiersData.offset?e.modifiersData.offset[e.placement][y]:0,K=E[y]+z-V-U,X=E[y]+q-V;if(o){var Y=oe(f?ne(S,K):S,D,f?ie(N,X):N);E[y]=Y,C[y]=Y-D}if(a){var Q="x"===y?mt:bt,G="x"===y?gt:_t,Z=E[w],J=Z+g[Q],tt=Z-g[G],et=oe(f?ne(J,K):J,Z,f?ie(tt,X):tt);E[w]=et,C[w]=et-Z}}e.modifiersData[n]=C}},requiresIfExists:["offset"]};function Me(t,e,i){void 0===i&&(i=!1);var n=zt(e);zt(e)&&function(t){var e=t.getBoundingClientRect();e.width,t.offsetWidth,e.height,t.offsetHeight}(e);var s,o,r=Gt(e),a=Vt(t),l={scrollLeft:0,scrollTop:0},c={x:0,y:0};return(n||!n&&!i)&&(("body"!==Rt(e)||we(r))&&(l=(s=e)!==Wt(s)&&zt(s)?{scrollLeft:(o=s).scrollLeft,scrollTop:o.scrollTop}:ve(s)),zt(e)?((c=Vt(e)).x+=e.clientLeft,c.y+=e.clientTop):r&&(c.x=ye(r))),{x:a.left+l.scrollLeft-c.x,y:a.top+l.scrollTop-c.y,width:a.width,height:a.height}}function He(t){var e=new Map,i=new Set,n=[];function s(t){i.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach((function(t){if(!i.has(t)){var n=e.get(t);n&&s(n)}})),n.push(t)}return t.forEach((function(t){e.set(t.name,t)})),t.forEach((function(t){i.has(t.name)||s(t)})),n}var Be={placement:"bottom",modifiers:[],strategy:"absolute"};function Re(){for(var t=arguments.length,e=new Array(t),i=0;ij.on(t,"mouseover",d))),this._element.focus(),this._element.setAttribute("aria-expanded",!0),this._menu.classList.add(Je),this._element.classList.add(Je),j.trigger(this._element,"shown.bs.dropdown",t)}hide(){if(c(this._element)||!this._isShown(this._menu))return;const t={relatedTarget:this._element};this._completeHide(t)}dispose(){this._popper&&this._popper.destroy(),super.dispose()}update(){this._inNavbar=this._detectNavbar(),this._popper&&this._popper.update()}_completeHide(t){j.trigger(this._element,"hide.bs.dropdown",t).defaultPrevented||("ontouchstart"in document.documentElement&&[].concat(...document.body.children).forEach((t=>j.off(t,"mouseover",d))),this._popper&&this._popper.destroy(),this._menu.classList.remove(Je),this._element.classList.remove(Je),this._element.setAttribute("aria-expanded","false"),U.removeDataAttribute(this._menu,"popper"),j.trigger(this._element,"hidden.bs.dropdown",t))}_getConfig(t){if(t={...this.constructor.Default,...U.getDataAttributes(this._element),...t},a(Ue,t,this.constructor.DefaultType),"object"==typeof t.reference&&!o(t.reference)&&"function"!=typeof t.reference.getBoundingClientRect)throw new TypeError(`${Ue.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`);return t}_createPopper(t){if(void 0===Fe)throw new TypeError("Bootstrap's dropdowns require Popper (https://popper.js.org)");let e=this._element;"parent"===this._config.reference?e=t:o(this._config.reference)?e=r(this._config.reference):"object"==typeof this._config.reference&&(e=this._config.reference);const i=this._getPopperConfig(),n=i.modifiers.find((t=>"applyStyles"===t.name&&!1===t.enabled));this._popper=qe(e,this._menu,i),n&&U.setDataAttribute(this._menu,"popper","static")}_isShown(t=this._element){return t.classList.contains(Je)}_getMenuElement(){return V.next(this._element,ei)[0]}_getPlacement(){const t=this._element.parentNode;if(t.classList.contains("dropend"))return ri;if(t.classList.contains("dropstart"))return ai;const e="end"===getComputedStyle(this._menu).getPropertyValue("--bs-position").trim();return t.classList.contains("dropup")?e?ni:ii:e?oi:si}_detectNavbar(){return null!==this._element.closest(".navbar")}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return"static"===this._config.display&&(t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,..."function"==typeof this._config.popperConfig?this._config.popperConfig(t):this._config.popperConfig}}_selectMenuItem({key:t,target:e}){const i=V.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter(l);i.length&&v(i,e,t===Ye,!i.includes(e)).focus()}static jQueryInterface(t){return this.each((function(){const e=hi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}static clearMenus(t){if(t&&(2===t.button||"keyup"===t.type&&"Tab"!==t.key))return;const e=V.find(ti);for(let i=0,n=e.length;ie+t)),this._setElementAttributes(di,"paddingRight",(e=>e+t)),this._setElementAttributes(ui,"marginRight",(e=>e-t))}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const n=this.getWidth();this._applyManipulationCallback(t,(t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+n)return;this._saveInitialAttribute(t,e);const s=window.getComputedStyle(t)[e];t.style[e]=`${i(Number.parseFloat(s))}px`}))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,"paddingRight"),this._resetElementAttributes(di,"paddingRight"),this._resetElementAttributes(ui,"marginRight")}_saveInitialAttribute(t,e){const i=t.style[e];i&&U.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,(t=>{const i=U.getDataAttribute(t,e);void 0===i?t.style.removeProperty(e):(U.removeDataAttribute(t,e),t.style[e]=i)}))}_applyManipulationCallback(t,e){o(t)?e(t):V.find(t,this._element).forEach(e)}isOverflowing(){return this.getWidth()>0}}const pi={className:"modal-backdrop",isVisible:!0,isAnimated:!1,rootElement:"body",clickCallback:null},mi={className:"string",isVisible:"boolean",isAnimated:"boolean",rootElement:"(element|string)",clickCallback:"(function|null)"},gi="show",_i="mousedown.bs.backdrop";class bi{constructor(t){this._config=this._getConfig(t),this._isAppended=!1,this._element=null}show(t){this._config.isVisible?(this._append(),this._config.isAnimated&&u(this._getElement()),this._getElement().classList.add(gi),this._emulateAnimation((()=>{_(t)}))):_(t)}hide(t){this._config.isVisible?(this._getElement().classList.remove(gi),this._emulateAnimation((()=>{this.dispose(),_(t)}))):_(t)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_getConfig(t){return(t={...pi,..."object"==typeof t?t:{}}).rootElement=r(t.rootElement),a("backdrop",t,mi),t}_append(){this._isAppended||(this._config.rootElement.append(this._getElement()),j.on(this._getElement(),_i,(()=>{_(this._config.clickCallback)})),this._isAppended=!0)}dispose(){this._isAppended&&(j.off(this._element,_i),this._element.remove(),this._isAppended=!1)}_emulateAnimation(t){b(t,this._getElement(),this._config.isAnimated)}}const vi={trapElement:null,autofocus:!0},yi={trapElement:"element",autofocus:"boolean"},wi=".bs.focustrap",Ei="backward";class Ai{constructor(t){this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}activate(){const{trapElement:t,autofocus:e}=this._config;this._isActive||(e&&t.focus(),j.off(document,wi),j.on(document,"focusin.bs.focustrap",(t=>this._handleFocusin(t))),j.on(document,"keydown.tab.bs.focustrap",(t=>this._handleKeydown(t))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,j.off(document,wi))}_handleFocusin(t){const{target:e}=t,{trapElement:i}=this._config;if(e===document||e===i||i.contains(e))return;const n=V.focusableChildren(i);0===n.length?i.focus():this._lastTabNavDirection===Ei?n[n.length-1].focus():n[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?Ei:"forward")}_getConfig(t){return t={...vi,..."object"==typeof t?t:{}},a("focustrap",t,yi),t}}const Ti="modal",Oi="Escape",Ci={backdrop:!0,keyboard:!0,focus:!0},ki={backdrop:"(boolean|string)",keyboard:"boolean",focus:"boolean"},Li="hidden.bs.modal",xi="show.bs.modal",Di="resize.bs.modal",Si="click.dismiss.bs.modal",Ni="keydown.dismiss.bs.modal",Ii="mousedown.dismiss.bs.modal",Pi="modal-open",ji="show",Mi="modal-static";class Hi extends B{constructor(t,e){super(t),this._config=this._getConfig(e),this._dialog=V.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._ignoreBackdropClick=!1,this._isTransitioning=!1,this._scrollBar=new fi}static get Default(){return Ci}static get NAME(){return Ti}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||j.trigger(this._element,xi,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isAnimated()&&(this._isTransitioning=!0),this._scrollBar.hide(),document.body.classList.add(Pi),this._adjustDialog(),this._setEscapeEvent(),this._setResizeEvent(),j.on(this._dialog,Ii,(()=>{j.one(this._element,"mouseup.dismiss.bs.modal",(t=>{t.target===this._element&&(this._ignoreBackdropClick=!0)}))})),this._showBackdrop((()=>this._showElement(t))))}hide(){if(!this._isShown||this._isTransitioning)return;if(j.trigger(this._element,"hide.bs.modal").defaultPrevented)return;this._isShown=!1;const t=this._isAnimated();t&&(this._isTransitioning=!0),this._setEscapeEvent(),this._setResizeEvent(),this._focustrap.deactivate(),this._element.classList.remove(ji),j.off(this._element,Si),j.off(this._dialog,Ii),this._queueCallback((()=>this._hideModal()),this._element,t)}dispose(){[window,this._dialog].forEach((t=>j.off(t,".bs.modal"))),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new bi({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new Ai({trapElement:this._element})}_getConfig(t){return t={...Ci,...U.getDataAttributes(this._element),..."object"==typeof t?t:{}},a(Ti,t,ki),t}_showElement(t){const e=this._isAnimated(),i=V.findOne(".modal-body",this._dialog);this._element.parentNode&&this._element.parentNode.nodeType===Node.ELEMENT_NODE||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0,i&&(i.scrollTop=0),e&&u(this._element),this._element.classList.add(ji),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,j.trigger(this._element,"shown.bs.modal",{relatedTarget:t})}),this._dialog,e)}_setEscapeEvent(){this._isShown?j.on(this._element,Ni,(t=>{this._config.keyboard&&t.key===Oi?(t.preventDefault(),this.hide()):this._config.keyboard||t.key!==Oi||this._triggerBackdropTransition()})):j.off(this._element,Ni)}_setResizeEvent(){this._isShown?j.on(window,Di,(()=>this._adjustDialog())):j.off(window,Di)}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove(Pi),this._resetAdjustments(),this._scrollBar.reset(),j.trigger(this._element,Li)}))}_showBackdrop(t){j.on(this._element,Si,(t=>{this._ignoreBackdropClick?this._ignoreBackdropClick=!1:t.target===t.currentTarget&&(!0===this._config.backdrop?this.hide():"static"===this._config.backdrop&&this._triggerBackdropTransition())})),this._backdrop.show(t)}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(j.trigger(this._element,"hidePrevented.bs.modal").defaultPrevented)return;const{classList:t,scrollHeight:e,style:i}=this._element,n=e>document.documentElement.clientHeight;!n&&"hidden"===i.overflowY||t.contains(Mi)||(n||(i.overflowY="hidden"),t.add(Mi),this._queueCallback((()=>{t.remove(Mi),n||this._queueCallback((()=>{i.overflowY=""}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;(!i&&t&&!m()||i&&!t&&m())&&(this._element.style.paddingLeft=`${e}px`),(i&&!t&&!m()||!i&&t&&m())&&(this._element.style.paddingRight=`${e}px`)}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=Hi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}j.on(document,"click.bs.modal.data-api",'[data-bs-toggle="modal"]',(function(t){const e=n(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),j.one(e,xi,(t=>{t.defaultPrevented||j.one(e,Li,(()=>{l(this)&&this.focus()}))}));const i=V.findOne(".modal.show");i&&Hi.getInstance(i).hide(),Hi.getOrCreateInstance(e).toggle(this)})),R(Hi),g(Hi);const Bi="offcanvas",Ri={backdrop:!0,keyboard:!0,scroll:!1},Wi={backdrop:"boolean",keyboard:"boolean",scroll:"boolean"},$i="show",zi=".offcanvas.show",qi="hidden.bs.offcanvas";class Fi extends B{constructor(t,e){super(t),this._config=this._getConfig(e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get NAME(){return Bi}static get Default(){return Ri}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||j.trigger(this._element,"show.bs.offcanvas",{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._element.style.visibility="visible",this._backdrop.show(),this._config.scroll||(new fi).hide(),this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add($i),this._queueCallback((()=>{this._config.scroll||this._focustrap.activate(),j.trigger(this._element,"shown.bs.offcanvas",{relatedTarget:t})}),this._element,!0))}hide(){this._isShown&&(j.trigger(this._element,"hide.bs.offcanvas").defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.remove($i),this._backdrop.hide(),this._queueCallback((()=>{this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._element.style.visibility="hidden",this._config.scroll||(new fi).reset(),j.trigger(this._element,qi)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_getConfig(t){return t={...Ri,...U.getDataAttributes(this._element),..."object"==typeof t?t:{}},a(Bi,t,Wi),t}_initializeBackDrop(){return new bi({className:"offcanvas-backdrop",isVisible:this._config.backdrop,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:()=>this.hide()})}_initializeFocusTrap(){return new Ai({trapElement:this._element})}_addEventListeners(){j.on(this._element,"keydown.dismiss.bs.offcanvas",(t=>{this._config.keyboard&&"Escape"===t.key&&this.hide()}))}static jQueryInterface(t){return this.each((function(){const e=Fi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}j.on(document,"click.bs.offcanvas.data-api",'[data-bs-toggle="offcanvas"]',(function(t){const e=n(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),c(this))return;j.one(e,qi,(()=>{l(this)&&this.focus()}));const i=V.findOne(zi);i&&i!==e&&Fi.getInstance(i).hide(),Fi.getOrCreateInstance(e).toggle(this)})),j.on(window,"load.bs.offcanvas.data-api",(()=>V.find(zi).forEach((t=>Fi.getOrCreateInstance(t).show())))),R(Fi),g(Fi);const Ui=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Vi=/^(?:(?:https?|mailto|ftp|tel|file|sms):|[^#&/:?]*(?:[#/?]|$))/i,Ki=/^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[\d+/a-z]+=*$/i,Xi=(t,e)=>{const i=t.nodeName.toLowerCase();if(e.includes(i))return!Ui.has(i)||Boolean(Vi.test(t.nodeValue)||Ki.test(t.nodeValue));const n=e.filter((t=>t instanceof RegExp));for(let t=0,e=n.length;t{Xi(t,r)||i.removeAttribute(t.nodeName)}))}return n.body.innerHTML}const Qi="tooltip",Gi=new Set(["sanitize","allowList","sanitizeFn"]),Zi={animation:"boolean",template:"string",title:"(string|element|function)",trigger:"string",delay:"(number|object)",html:"boolean",selector:"(string|boolean)",placement:"(string|function)",offset:"(array|string|function)",container:"(string|element|boolean)",fallbackPlacements:"array",boundary:"(string|element)",customClass:"(string|function)",sanitize:"boolean",sanitizeFn:"(null|function)",allowList:"object",popperConfig:"(null|object|function)"},Ji={AUTO:"auto",TOP:"top",RIGHT:m()?"left":"right",BOTTOM:"bottom",LEFT:m()?"right":"left"},tn={animation:!0,template:'',trigger:"hover focus",title:"",delay:0,html:!1,selector:!1,placement:"top",offset:[0,0],container:!1,fallbackPlacements:["top","right","bottom","left"],boundary:"clippingParents",customClass:"",sanitize:!0,sanitizeFn:null,allowList:{"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],div:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},popperConfig:null},en={HIDE:"hide.bs.tooltip",HIDDEN:"hidden.bs.tooltip",SHOW:"show.bs.tooltip",SHOWN:"shown.bs.tooltip",INSERTED:"inserted.bs.tooltip",CLICK:"click.bs.tooltip",FOCUSIN:"focusin.bs.tooltip",FOCUSOUT:"focusout.bs.tooltip",MOUSEENTER:"mouseenter.bs.tooltip",MOUSELEAVE:"mouseleave.bs.tooltip"},nn="fade",sn="show",on="show",rn="out",an=".tooltip-inner",ln=".modal",cn="hide.bs.modal",hn="hover",dn="focus";class un extends B{constructor(t,e){if(void 0===Fe)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t),this._isEnabled=!0,this._timeout=0,this._hoverState="",this._activeTrigger={},this._popper=null,this._config=this._getConfig(e),this.tip=null,this._setListeners()}static get Default(){return tn}static get NAME(){return Qi}static get Event(){return en}static get DefaultType(){return Zi}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(t){if(this._isEnabled)if(t){const e=this._initializeOnDelegatedTarget(t);e._activeTrigger.click=!e._activeTrigger.click,e._isWithActiveTrigger()?e._enter(null,e):e._leave(null,e)}else{if(this.getTipElement().classList.contains(sn))return void this._leave(null,this);this._enter(null,this)}}dispose(){clearTimeout(this._timeout),j.off(this._element.closest(ln),cn,this._hideModalHandler),this.tip&&this.tip.remove(),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this.isWithContent()||!this._isEnabled)return;const t=j.trigger(this._element,this.constructor.Event.SHOW),e=h(this._element),i=null===e?this._element.ownerDocument.documentElement.contains(this._element):e.contains(this._element);if(t.defaultPrevented||!i)return;"tooltip"===this.constructor.NAME&&this.tip&&this.getTitle()!==this.tip.querySelector(an).innerHTML&&(this._disposePopper(),this.tip.remove(),this.tip=null);const n=this.getTipElement(),s=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME);n.setAttribute("id",s),this._element.setAttribute("aria-describedby",s),this._config.animation&&n.classList.add(nn);const o="function"==typeof this._config.placement?this._config.placement.call(this,n,this._element):this._config.placement,r=this._getAttachment(o);this._addAttachmentClass(r);const{container:a}=this._config;H.set(n,this.constructor.DATA_KEY,this),this._element.ownerDocument.documentElement.contains(this.tip)||(a.append(n),j.trigger(this._element,this.constructor.Event.INSERTED)),this._popper?this._popper.update():this._popper=qe(this._element,n,this._getPopperConfig(r)),n.classList.add(sn);const l=this._resolvePossibleFunction(this._config.customClass);l&&n.classList.add(...l.split(" ")),"ontouchstart"in document.documentElement&&[].concat(...document.body.children).forEach((t=>{j.on(t,"mouseover",d)}));const c=this.tip.classList.contains(nn);this._queueCallback((()=>{const t=this._hoverState;this._hoverState=null,j.trigger(this._element,this.constructor.Event.SHOWN),t===rn&&this._leave(null,this)}),this.tip,c)}hide(){if(!this._popper)return;const t=this.getTipElement();if(j.trigger(this._element,this.constructor.Event.HIDE).defaultPrevented)return;t.classList.remove(sn),"ontouchstart"in document.documentElement&&[].concat(...document.body.children).forEach((t=>j.off(t,"mouseover",d))),this._activeTrigger.click=!1,this._activeTrigger.focus=!1,this._activeTrigger.hover=!1;const e=this.tip.classList.contains(nn);this._queueCallback((()=>{this._isWithActiveTrigger()||(this._hoverState!==on&&t.remove(),this._cleanTipClass(),this._element.removeAttribute("aria-describedby"),j.trigger(this._element,this.constructor.Event.HIDDEN),this._disposePopper())}),this.tip,e),this._hoverState=""}update(){null!==this._popper&&this._popper.update()}isWithContent(){return Boolean(this.getTitle())}getTipElement(){if(this.tip)return this.tip;const t=document.createElement("div");t.innerHTML=this._config.template;const e=t.children[0];return this.setContent(e),e.classList.remove(nn,sn),this.tip=e,this.tip}setContent(t){this._sanitizeAndSetContent(t,this.getTitle(),an)}_sanitizeAndSetContent(t,e,i){const n=V.findOne(i,t);e||!n?this.setElementContent(n,e):n.remove()}setElementContent(t,e){if(null!==t)return o(e)?(e=r(e),void(this._config.html?e.parentNode!==t&&(t.innerHTML="",t.append(e)):t.textContent=e.textContent)):void(this._config.html?(this._config.sanitize&&(e=Yi(e,this._config.allowList,this._config.sanitizeFn)),t.innerHTML=e):t.textContent=e)}getTitle(){const t=this._element.getAttribute("data-bs-original-title")||this._config.title;return this._resolvePossibleFunction(t)}updateAttachment(t){return"right"===t?"end":"left"===t?"start":t}_initializeOnDelegatedTarget(t,e){return e||this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return"function"==typeof t?t.call(this._element):t}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"onChange",enabled:!0,phase:"afterWrite",fn:t=>this._handlePopperPlacementChange(t)}],onFirstUpdate:t=>{t.options.placement!==t.placement&&this._handlePopperPlacementChange(t)}};return{...e,..."function"==typeof this._config.popperConfig?this._config.popperConfig(e):this._config.popperConfig}}_addAttachmentClass(t){this.getTipElement().classList.add(`${this._getBasicClassPrefix()}-${this.updateAttachment(t)}`)}_getAttachment(t){return Ji[t.toUpperCase()]}_setListeners(){this._config.trigger.split(" ").forEach((t=>{if("click"===t)j.on(this._element,this.constructor.Event.CLICK,this._config.selector,(t=>this.toggle(t)));else if("manual"!==t){const e=t===hn?this.constructor.Event.MOUSEENTER:this.constructor.Event.FOCUSIN,i=t===hn?this.constructor.Event.MOUSELEAVE:this.constructor.Event.FOCUSOUT;j.on(this._element,e,this._config.selector,(t=>this._enter(t))),j.on(this._element,i,this._config.selector,(t=>this._leave(t)))}})),this._hideModalHandler=()=>{this._element&&this.hide()},j.on(this._element.closest(ln),cn,this._hideModalHandler),this._config.selector?this._config={...this._config,trigger:"manual",selector:""}:this._fixTitle()}_fixTitle(){const t=this._element.getAttribute("title"),e=typeof this._element.getAttribute("data-bs-original-title");(t||"string"!==e)&&(this._element.setAttribute("data-bs-original-title",t||""),!t||this._element.getAttribute("aria-label")||this._element.textContent||this._element.setAttribute("aria-label",t),this._element.setAttribute("title",""))}_enter(t,e){e=this._initializeOnDelegatedTarget(t,e),t&&(e._activeTrigger["focusin"===t.type?dn:hn]=!0),e.getTipElement().classList.contains(sn)||e._hoverState===on?e._hoverState=on:(clearTimeout(e._timeout),e._hoverState=on,e._config.delay&&e._config.delay.show?e._timeout=setTimeout((()=>{e._hoverState===on&&e.show()}),e._config.delay.show):e.show())}_leave(t,e){e=this._initializeOnDelegatedTarget(t,e),t&&(e._activeTrigger["focusout"===t.type?dn:hn]=e._element.contains(t.relatedTarget)),e._isWithActiveTrigger()||(clearTimeout(e._timeout),e._hoverState=rn,e._config.delay&&e._config.delay.hide?e._timeout=setTimeout((()=>{e._hoverState===rn&&e.hide()}),e._config.delay.hide):e.hide())}_isWithActiveTrigger(){for(const t in this._activeTrigger)if(this._activeTrigger[t])return!0;return!1}_getConfig(t){const e=U.getDataAttributes(this._element);return Object.keys(e).forEach((t=>{Gi.has(t)&&delete e[t]})),(t={...this.constructor.Default,...e,..."object"==typeof t&&t?t:{}}).container=!1===t.container?document.body:r(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),a(Qi,t,this.constructor.DefaultType),t.sanitize&&(t.template=Yi(t.template,t.allowList,t.sanitizeFn)),t}_getDelegateConfig(){const t={};for(const e in this._config)this.constructor.Default[e]!==this._config[e]&&(t[e]=this._config[e]);return t}_cleanTipClass(){const t=this.getTipElement(),e=new RegExp(`(^|\\s)${this._getBasicClassPrefix()}\\S+`,"g"),i=t.getAttribute("class").match(e);null!==i&&i.length>0&&i.map((t=>t.trim())).forEach((e=>t.classList.remove(e)))}_getBasicClassPrefix(){return"bs-tooltip"}_handlePopperPlacementChange(t){const{state:e}=t;e&&(this.tip=e.elements.popper,this._cleanTipClass(),this._addAttachmentClass(this._getAttachment(e.placement)))}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null)}static jQueryInterface(t){return this.each((function(){const e=un.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}g(un);const fn={...un.Default,placement:"right",offset:[0,8],trigger:"click",content:"",template:''},pn={...un.DefaultType,content:"(string|element|function)"},mn={HIDE:"hide.bs.popover",HIDDEN:"hidden.bs.popover",SHOW:"show.bs.popover",SHOWN:"shown.bs.popover",INSERTED:"inserted.bs.popover",CLICK:"click.bs.popover",FOCUSIN:"focusin.bs.popover",FOCUSOUT:"focusout.bs.popover",MOUSEENTER:"mouseenter.bs.popover",MOUSELEAVE:"mouseleave.bs.popover"};class gn extends un{static get Default(){return fn}static get NAME(){return"popover"}static get Event(){return mn}static get DefaultType(){return pn}isWithContent(){return this.getTitle()||this._getContent()}setContent(t){this._sanitizeAndSetContent(t,this.getTitle(),".popover-header"),this._sanitizeAndSetContent(t,this._getContent(),".popover-body")}_getContent(){return this._resolvePossibleFunction(this._config.content)}_getBasicClassPrefix(){return"bs-popover"}static jQueryInterface(t){return this.each((function(){const e=gn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}g(gn);const _n="scrollspy",bn={offset:10,method:"auto",target:""},vn={offset:"number",method:"string",target:"(string|element)"},yn="active",wn=".nav-link, .list-group-item, .dropdown-item",En="position";class An extends B{constructor(t,e){super(t),this._scrollElement="BODY"===this._element.tagName?window:this._element,this._config=this._getConfig(e),this._offsets=[],this._targets=[],this._activeTarget=null,this._scrollHeight=0,j.on(this._scrollElement,"scroll.bs.scrollspy",(()=>this._process())),this.refresh(),this._process()}static get Default(){return bn}static get NAME(){return _n}refresh(){const t=this._scrollElement===this._scrollElement.window?"offset":En,e="auto"===this._config.method?t:this._config.method,n=e===En?this._getScrollTop():0;this._offsets=[],this._targets=[],this._scrollHeight=this._getScrollHeight(),V.find(wn,this._config.target).map((t=>{const s=i(t),o=s?V.findOne(s):null;if(o){const t=o.getBoundingClientRect();if(t.width||t.height)return[U[e](o).top+n,s]}return null})).filter((t=>t)).sort(((t,e)=>t[0]-e[0])).forEach((t=>{this._offsets.push(t[0]),this._targets.push(t[1])}))}dispose(){j.off(this._scrollElement,".bs.scrollspy"),super.dispose()}_getConfig(t){return(t={...bn,...U.getDataAttributes(this._element),..."object"==typeof t&&t?t:{}}).target=r(t.target)||document.documentElement,a(_n,t,vn),t}_getScrollTop(){return this._scrollElement===window?this._scrollElement.pageYOffset:this._scrollElement.scrollTop}_getScrollHeight(){return this._scrollElement.scrollHeight||Math.max(document.body.scrollHeight,document.documentElement.scrollHeight)}_getOffsetHeight(){return this._scrollElement===window?window.innerHeight:this._scrollElement.getBoundingClientRect().height}_process(){const t=this._getScrollTop()+this._config.offset,e=this._getScrollHeight(),i=this._config.offset+e-this._getOffsetHeight();if(this._scrollHeight!==e&&this.refresh(),t>=i){const t=this._targets[this._targets.length-1];this._activeTarget!==t&&this._activate(t)}else{if(this._activeTarget&&t0)return this._activeTarget=null,void this._clear();for(let e=this._offsets.length;e--;)this._activeTarget!==this._targets[e]&&t>=this._offsets[e]&&(void 0===this._offsets[e+1]||t`${e}[data-bs-target="${t}"],${e}[href="${t}"]`)),i=V.findOne(e.join(","),this._config.target);i.classList.add(yn),i.classList.contains("dropdown-item")?V.findOne(".dropdown-toggle",i.closest(".dropdown")).classList.add(yn):V.parents(i,".nav, .list-group").forEach((t=>{V.prev(t,".nav-link, .list-group-item").forEach((t=>t.classList.add(yn))),V.prev(t,".nav-item").forEach((t=>{V.children(t,".nav-link").forEach((t=>t.classList.add(yn)))}))})),j.trigger(this._scrollElement,"activate.bs.scrollspy",{relatedTarget:t})}_clear(){V.find(wn,this._config.target).filter((t=>t.classList.contains(yn))).forEach((t=>t.classList.remove(yn)))}static jQueryInterface(t){return this.each((function(){const e=An.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}j.on(window,"load.bs.scrollspy.data-api",(()=>{V.find('[data-bs-spy="scroll"]').forEach((t=>new An(t)))})),g(An);const Tn="active",On="fade",Cn="show",kn=".active",Ln=":scope > li > .active";class xn extends B{static get NAME(){return"tab"}show(){if(this._element.parentNode&&this._element.parentNode.nodeType===Node.ELEMENT_NODE&&this._element.classList.contains(Tn))return;let t;const e=n(this._element),i=this._element.closest(".nav, .list-group");if(i){const e="UL"===i.nodeName||"OL"===i.nodeName?Ln:kn;t=V.find(e,i),t=t[t.length-1]}const s=t?j.trigger(t,"hide.bs.tab",{relatedTarget:this._element}):null;if(j.trigger(this._element,"show.bs.tab",{relatedTarget:t}).defaultPrevented||null!==s&&s.defaultPrevented)return;this._activate(this._element,i);const o=()=>{j.trigger(t,"hidden.bs.tab",{relatedTarget:this._element}),j.trigger(this._element,"shown.bs.tab",{relatedTarget:t})};e?this._activate(e,e.parentNode,o):o()}_activate(t,e,i){const n=(!e||"UL"!==e.nodeName&&"OL"!==e.nodeName?V.children(e,kn):V.find(Ln,e))[0],s=i&&n&&n.classList.contains(On),o=()=>this._transitionComplete(t,n,i);n&&s?(n.classList.remove(Cn),this._queueCallback(o,t,!0)):o()}_transitionComplete(t,e,i){if(e){e.classList.remove(Tn);const t=V.findOne(":scope > .dropdown-menu .active",e.parentNode);t&&t.classList.remove(Tn),"tab"===e.getAttribute("role")&&e.setAttribute("aria-selected",!1)}t.classList.add(Tn),"tab"===t.getAttribute("role")&&t.setAttribute("aria-selected",!0),u(t),t.classList.contains(On)&&t.classList.add(Cn);let n=t.parentNode;if(n&&"LI"===n.nodeName&&(n=n.parentNode),n&&n.classList.contains("dropdown-menu")){const e=t.closest(".dropdown");e&&V.find(".dropdown-toggle",e).forEach((t=>t.classList.add(Tn))),t.setAttribute("aria-expanded",!0)}i&&i()}static jQueryInterface(t){return this.each((function(){const e=xn.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}j.on(document,"click.bs.tab.data-api",'[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),c(this)||xn.getOrCreateInstance(this).show()})),g(xn);const Dn="toast",Sn="hide",Nn="show",In="showing",Pn={animation:"boolean",autohide:"boolean",delay:"number"},jn={animation:!0,autohide:!0,delay:5e3};class Mn extends B{constructor(t,e){super(t),this._config=this._getConfig(e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get DefaultType(){return Pn}static get Default(){return jn}static get NAME(){return Dn}show(){j.trigger(this._element,"show.bs.toast").defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(Sn),u(this._element),this._element.classList.add(Nn),this._element.classList.add(In),this._queueCallback((()=>{this._element.classList.remove(In),j.trigger(this._element,"shown.bs.toast"),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this._element.classList.contains(Nn)&&(j.trigger(this._element,"hide.bs.toast").defaultPrevented||(this._element.classList.add(In),this._queueCallback((()=>{this._element.classList.add(Sn),this._element.classList.remove(In),this._element.classList.remove(Nn),j.trigger(this._element,"hidden.bs.toast")}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this._element.classList.contains(Nn)&&this._element.classList.remove(Nn),super.dispose()}_getConfig(t){return t={...jn,...U.getDataAttributes(this._element),..."object"==typeof t&&t?t:{}},a(Dn,t,this.constructor.DefaultType),t}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){j.on(this._element,"mouseover.bs.toast",(t=>this._onInteraction(t,!0))),j.on(this._element,"mouseout.bs.toast",(t=>this._onInteraction(t,!1))),j.on(this._element,"focusin.bs.toast",(t=>this._onInteraction(t,!0))),j.on(this._element,"focusout.bs.toast",(t=>this._onInteraction(t,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=Mn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return R(Mn),g(Mn),{Alert:W,Button:z,Carousel:st,Collapse:pt,Dropdown:hi,Modal:Hi,Offcanvas:Fi,Popover:gn,ScrollSpy:An,Tab:xn,Toast:Mn,Tooltip:un}})); +//# sourceMappingURL=bootstrap.bundle.min.js.map \ No newline at end of file diff --git a/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js b/src/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js similarity index 100% rename from wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js rename to src/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js diff --git a/wwwroot/lib/bootstrap/dist/js/bootstrap.js b/src/wwwroot/lib/bootstrap/dist/js/bootstrap.js similarity index 100% rename from wwwroot/lib/bootstrap/dist/js/bootstrap.js rename to src/wwwroot/lib/bootstrap/dist/js/bootstrap.js diff --git a/wwwroot/lib/jquery-validation-unobtrusive/LICENSE.txt b/src/wwwroot/lib/jquery-validation-unobtrusive/LICENSE.txt similarity index 100% rename from wwwroot/lib/jquery-validation-unobtrusive/LICENSE.txt rename to src/wwwroot/lib/jquery-validation-unobtrusive/LICENSE.txt diff --git a/wwwroot/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js b/src/wwwroot/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js similarity index 100% rename from wwwroot/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js rename to src/wwwroot/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js diff --git a/wwwroot/lib/jquery-validation/LICENSE.md b/src/wwwroot/lib/jquery-validation/LICENSE.md similarity index 100% rename from wwwroot/lib/jquery-validation/LICENSE.md rename to src/wwwroot/lib/jquery-validation/LICENSE.md diff --git a/wwwroot/lib/jquery-validation/dist/additional-methods.js b/src/wwwroot/lib/jquery-validation/dist/additional-methods.js similarity index 100% rename from wwwroot/lib/jquery-validation/dist/additional-methods.js rename to src/wwwroot/lib/jquery-validation/dist/additional-methods.js diff --git a/wwwroot/lib/jquery-validation/dist/jquery.validate.js b/src/wwwroot/lib/jquery-validation/dist/jquery.validate.js similarity index 100% rename from wwwroot/lib/jquery-validation/dist/jquery.validate.js rename to src/wwwroot/lib/jquery-validation/dist/jquery.validate.js diff --git a/wwwroot/lib/jquery/LICENSE.txt b/src/wwwroot/lib/jquery/LICENSE.txt similarity index 100% rename from wwwroot/lib/jquery/LICENSE.txt rename to src/wwwroot/lib/jquery/LICENSE.txt diff --git a/wwwroot/lib/jquery/dist/jquery.js b/src/wwwroot/lib/jquery/dist/jquery.js similarity index 100% rename from wwwroot/lib/jquery/dist/jquery.js rename to src/wwwroot/lib/jquery/dist/jquery.js diff --git a/src/wwwroot/lib/jquery/dist/jquery.min.js b/src/wwwroot/lib/jquery/dist/jquery.min.js new file mode 100644 index 0000000..7f37b5d --- /dev/null +++ b/src/wwwroot/lib/jquery/dist/jquery.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.7.1 | (c) OpenJS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(ie,e){"use strict";var oe=[],r=Object.getPrototypeOf,ae=oe.slice,g=oe.flat?function(e){return oe.flat.call(e)}:function(e){return oe.concat.apply([],e)},s=oe.push,se=oe.indexOf,n={},i=n.toString,ue=n.hasOwnProperty,o=ue.toString,a=o.call(Object),le={},v=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},y=function(e){return null!=e&&e===e.window},C=ie.document,u={type:!0,src:!0,nonce:!0,noModule:!0};function m(e,t,n){var r,i,o=(n=n||C).createElement("script");if(o.text=e,t)for(r in u)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function x(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[i.call(e)]||"object":typeof e}var t="3.7.1",l=/HTML$/i,ce=function(e,t){return new ce.fn.init(e,t)};function c(e){var t=!!e&&"length"in e&&e.length,n=x(e);return!v(e)&&!y(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+ge+")"+ge+"*"),x=new RegExp(ge+"|>"),j=new RegExp(g),A=new RegExp("^"+t+"$"),D={ID:new RegExp("^#("+t+")"),CLASS:new RegExp("^\\.("+t+")"),TAG:new RegExp("^("+t+"|[*])"),ATTR:new RegExp("^"+p),PSEUDO:new RegExp("^"+g),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+ge+"*(even|odd|(([+-]|)(\\d*)n|)"+ge+"*(?:([+-]|)"+ge+"*(\\d+)|))"+ge+"*\\)|)","i"),bool:new RegExp("^(?:"+f+")$","i"),needsContext:new RegExp("^"+ge+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+ge+"*((?:-\\d)?\\d*)"+ge+"*\\)|)(?=[^-]|$)","i")},N=/^(?:input|select|textarea|button)$/i,q=/^h\d$/i,L=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,H=/[+~]/,O=new RegExp("\\\\[\\da-fA-F]{1,6}"+ge+"?|\\\\([^\\r\\n\\f])","g"),P=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},M=function(){V()},R=J(function(e){return!0===e.disabled&&fe(e,"fieldset")},{dir:"parentNode",next:"legend"});try{k.apply(oe=ae.call(ye.childNodes),ye.childNodes),oe[ye.childNodes.length].nodeType}catch(e){k={apply:function(e,t){me.apply(e,ae.call(t))},call:function(e){me.apply(e,ae.call(arguments,1))}}}function I(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(V(e),e=e||T,C)){if(11!==p&&(u=L.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return k.call(n,a),n}else if(f&&(a=f.getElementById(i))&&I.contains(e,a)&&a.id===i)return k.call(n,a),n}else{if(u[2])return k.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&e.getElementsByClassName)return k.apply(n,e.getElementsByClassName(i)),n}if(!(h[t+" "]||d&&d.test(t))){if(c=t,f=e,1===p&&(x.test(t)||m.test(t))){(f=H.test(t)&&U(e.parentNode)||e)==e&&le.scope||((s=e.getAttribute("id"))?s=ce.escapeSelector(s):e.setAttribute("id",s=S)),o=(l=Y(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+Q(l[o]);c=l.join(",")}try{return k.apply(n,f.querySelectorAll(c)),n}catch(e){h(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return re(t.replace(ve,"$1"),e,n,r)}function W(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function F(e){return e[S]=!0,e}function $(e){var t=T.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function B(t){return function(e){return fe(e,"input")&&e.type===t}}function _(t){return function(e){return(fe(e,"input")||fe(e,"button"))&&e.type===t}}function z(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&R(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function X(a){return F(function(o){return o=+o,F(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function U(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}function V(e){var t,n=e?e.ownerDocument||e:ye;return n!=T&&9===n.nodeType&&n.documentElement&&(r=(T=n).documentElement,C=!ce.isXMLDoc(T),i=r.matches||r.webkitMatchesSelector||r.msMatchesSelector,r.msMatchesSelector&&ye!=T&&(t=T.defaultView)&&t.top!==t&&t.addEventListener("unload",M),le.getById=$(function(e){return r.appendChild(e).id=ce.expando,!T.getElementsByName||!T.getElementsByName(ce.expando).length}),le.disconnectedMatch=$(function(e){return i.call(e,"*")}),le.scope=$(function(){return T.querySelectorAll(":scope")}),le.cssHas=$(function(){try{return T.querySelector(":has(*,:jqfake)"),!1}catch(e){return!0}}),le.getById?(b.filter.ID=function(e){var t=e.replace(O,P);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(O,P);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):t.querySelectorAll(e)},b.find.CLASS=function(e,t){if("undefined"!=typeof t.getElementsByClassName&&C)return t.getElementsByClassName(e)},d=[],$(function(e){var t;r.appendChild(e).innerHTML="",e.querySelectorAll("[selected]").length||d.push("\\["+ge+"*(?:value|"+f+")"),e.querySelectorAll("[id~="+S+"-]").length||d.push("~="),e.querySelectorAll("a#"+S+"+*").length||d.push(".#.+[+~]"),e.querySelectorAll(":checked").length||d.push(":checked"),(t=T.createElement("input")).setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),r.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&d.push(":enabled",":disabled"),(t=T.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||d.push("\\["+ge+"*name"+ge+"*="+ge+"*(?:''|\"\")")}),le.cssHas||d.push(":has"),d=d.length&&new RegExp(d.join("|")),l=function(e,t){if(e===t)return a=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!le.sortDetached&&t.compareDocumentPosition(e)===n?e===T||e.ownerDocument==ye&&I.contains(ye,e)?-1:t===T||t.ownerDocument==ye&&I.contains(ye,t)?1:o?se.call(o,e)-se.call(o,t):0:4&n?-1:1)}),T}for(e in I.matches=function(e,t){return I(e,null,null,t)},I.matchesSelector=function(e,t){if(V(e),C&&!h[t+" "]&&(!d||!d.test(t)))try{var n=i.call(e,t);if(n||le.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){h(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(O,P),e[3]=(e[3]||e[4]||e[5]||"").replace(O,P),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||I.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&I.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return D.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&j.test(n)&&(t=Y(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(O,P).toLowerCase();return"*"===e?function(){return!0}:function(e){return fe(e,t)}},CLASS:function(e){var t=s[e+" "];return t||(t=new RegExp("(^|"+ge+")"+e+"("+ge+"|$)"))&&s(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=I.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function T(e,n,r){return v(n)?ce.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?ce.grep(e,function(e){return e===n!==r}):"string"!=typeof n?ce.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(ce.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||k,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:S.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof ce?t[0]:t,ce.merge(this,ce.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:C,!0)),w.test(r[1])&&ce.isPlainObject(t))for(r in t)v(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=C.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):v(e)?void 0!==n.ready?n.ready(e):e(ce):ce.makeArray(e,this)}).prototype=ce.fn,k=ce(C);var E=/^(?:parents|prev(?:Until|All))/,j={children:!0,contents:!0,next:!0,prev:!0};function A(e,t){while((e=e[t])&&1!==e.nodeType);return e}ce.fn.extend({has:function(e){var t=ce(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,Ce=/^$|^module$|\/(?:java|ecma)script/i;xe=C.createDocumentFragment().appendChild(C.createElement("div")),(be=C.createElement("input")).setAttribute("type","radio"),be.setAttribute("checked","checked"),be.setAttribute("name","t"),xe.appendChild(be),le.checkClone=xe.cloneNode(!0).cloneNode(!0).lastChild.checked,xe.innerHTML="",le.noCloneChecked=!!xe.cloneNode(!0).lastChild.defaultValue,xe.innerHTML="",le.option=!!xe.lastChild;var ke={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function Se(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&fe(e,t)?ce.merge([e],n):n}function Ee(e,t){for(var n=0,r=e.length;n",""]);var je=/<|&#?\w+;/;function Ae(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function Re(e,t){return fe(e,"table")&&fe(11!==t.nodeType?t:t.firstChild,"tr")&&ce(e).children("tbody")[0]||e}function Ie(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function We(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Fe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(_.hasData(e)&&(s=_.get(e).events))for(i in _.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),C.head.appendChild(r[0])},abort:function(){i&&i()}}});var Jt,Kt=[],Zt=/(=)\?(?=&|$)|\?\?/;ce.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Kt.pop()||ce.expando+"_"+jt.guid++;return this[e]=!0,e}}),ce.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Zt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Zt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=v(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Zt,"$1"+r):!1!==e.jsonp&&(e.url+=(At.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||ce.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=ie[r],ie[r]=function(){o=arguments},n.always(function(){void 0===i?ce(ie).removeProp(r):ie[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Kt.push(r)),o&&v(i)&&i(o[0]),o=i=void 0}),"script"}),le.createHTMLDocument=((Jt=C.implementation.createHTMLDocument("").body).innerHTML="
",2===Jt.childNodes.length),ce.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(le.createHTMLDocument?((r=(t=C.implementation.createHTMLDocument("")).createElement("base")).href=C.location.href,t.head.appendChild(r)):t=C),o=!n&&[],(i=w.exec(e))?[t.createElement(i[1])]:(i=Ae([e],t,o),o&&o.length&&ce(o).remove(),ce.merge([],i.childNodes)));var r,i,o},ce.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(ce.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},ce.expr.pseudos.animated=function(t){return ce.grep(ce.timers,function(e){return t===e.elem}).length},ce.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=ce.css(e,"position"),c=ce(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=ce.css(e,"top"),u=ce.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),v(t)&&(t=t.call(e,n,ce.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},ce.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){ce.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===ce.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===ce.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=ce(e).offset()).top+=ce.css(e,"borderTopWidth",!0),i.left+=ce.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-ce.css(r,"marginTop",!0),left:t.left-i.left-ce.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===ce.css(e,"position"))e=e.offsetParent;return e||J})}}),ce.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;ce.fn[t]=function(e){return M(this,function(e,t,n){var r;if(y(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),ce.each(["top","left"],function(e,n){ce.cssHooks[n]=Ye(le.pixelPosition,function(e,t){if(t)return t=Ge(e,n),_e.test(t)?ce(e).position()[n]+"px":t})}),ce.each({Height:"height",Width:"width"},function(a,s){ce.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){ce.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return M(this,function(e,t,n){var r;return y(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?ce.css(e,t,i):ce.style(e,t,n,i)},s,n?e:void 0,n)}})}),ce.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){ce.fn[t]=function(e){return this.on(t,e)}}),ce.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.on("mouseenter",e).on("mouseleave",t||e)}}),ce.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){ce.fn[n]=function(e,t){return 0 _mockContext; + private readonly DeleteModel _deleteModel; + private readonly ITestOutputHelper _output; + + public DeleteModelTests(ITestOutputHelper output) + { + _output = output ?? throw new ArgumentNullException(nameof(output)); + _mockContext = new Mock(new DbContextOptions()); + _deleteModel = new DeleteModel(_mockContext.Object); + } + + private DbContextOptions GetContextOptions() + { + return new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: System.Guid.NewGuid().ToString()) + .Options; + } + + private List GetTestMovies() + { + return new List + { + new Movie + { + Id = 1, + Title = "Test Movie 1", + ReleaseDate = DateTime.Parse("1989-2-12"), + Genre = "Romantic Comedy", + Price = 7.99M, + Rating = "PG", // Add Rating + Timestamp = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 } // Initialize with a default value + }, + new Movie + { + Id = 2, + Title = "Test Movie 2", + ReleaseDate = DateTime.Parse("1984-3-13"), + Genre = "Comedy", + Price = 8.99M, + Rating = "PG", // Add Rating + Timestamp = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 } // Initialize with a default value + } + }; + } + + [Fact] + public async Task CanDeleteMovie() + { + // Arrange + var options = GetContextOptions(); + using (var context = new RazorPagesMovieContext(options)) + { + var movie = new Movie + { + Title = "Test Movie", + Genre = "Test Genre", + Price = 10M, + ReleaseDate = DateTime.Now, + Rating = "PG", // Add Rating + Timestamp = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 } // Add Timestamp + }; + + _output.WriteLine("=== Test Output ==="); + _output.WriteLine("Test: CanDeleteMovie"); + _output.WriteLine("Creating test movie..."); + + context.Movie.Add(movie); + await context.SaveChangesAsync(); + + _output.WriteLine($"Created movie: {movie.Title} (ID: {movie.Id})"); + _output.WriteLine($"Initial movie count: {await context.Movie.CountAsync()}"); + } + + using (var context = new RazorPagesMovieContext(options)) + { + var movie = await context.Movie.FirstOrDefaultAsync(); + Assert.NotNull(movie); + + _output.WriteLine($"Found movie to delete: {movie?.Title ?? "Unknown title"}"); + if (movie == null) + { + _output.WriteLine("Cannot delete: movie is null"); + throw new InvalidOperationException("Cannot delete null movie"); + } + + context.Movie.Remove(movie); + await context.SaveChangesAsync(); + _output.WriteLine($"Successfully deleted movie with ID: {movie.Id}"); + + _output.WriteLine("Movie deleted"); + } + + using (var context = new RazorPagesMovieContext(options)) + { + var count = await context.Movie.CountAsync(); + _output.WriteLine($"Final movie count: {count}"); + _output.WriteLine("==================="); + + Assert.Equal(0, count); + } + } + + [Fact] + // a method that creates a movie, then tries to delete a movie that doesn't exist in the database and check before and after the deletion - use _output.WriteLine with the same formatting as the other tests + public async Task CanNotDeleteMovie() + { + // Arrange + var options = GetContextOptions(); + using (var context = new RazorPagesMovieContext(options)) + { + var movie = new Movie + { + Title = "Test Movie", + Genre = "Test Genre", + Price = 10M, + ReleaseDate = DateTime.Now, + Rating = "PG", // Add Rating + Timestamp = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 } // Add Timestamp + }; + context.Movie.Add(movie); + context.SaveChanges(); + } + + // Act + using (var context = new RazorPagesMovieContext(options)) + { + var movie = await context.Movie.FirstOrDefaultAsync(m => m.Title == "Test Movie"); + _output.WriteLine("=== Test Output ==="); + if (movie != null) + { + _output.WriteLine($"Initial Movie: {movie.Title}"); + _output.WriteLine("Rmoving movie from the database..."); + context.Movie.Remove(movie); + await context.SaveChangesAsync(); + } + else + { + _output.WriteLine("Movie not found."); + } + + _output.WriteLine("Assert.NotNull(movie);"); + Assert.NotNull(movie); + } + + // Assert - check that the movie is deleted from the database + using (var context = new RazorPagesMovieContext(options)) + { + var movie = await context.Movie.FirstOrDefaultAsync(m => m.Title == "Test Movie"); + // convert movie object to string to print it + string str_movie = movie?.ToString() ?? "null"; + _output.WriteLine($"Deleted Movie: {str_movie}"); + _output.WriteLine("Assert.Null(movie);"); + Assert.Null(movie); + _output.WriteLine("Trying to fetch the movie by title: 'Test Movie' again, expected null"); + _output.WriteLine("Fetch by expression: context.Movie.FirstOrDefaultAsync(m => m.Title == 'Test Movie');"); + var movie2 = await context.Movie.FirstOrDefaultAsync(m => m.Title == "Test Movie"); + _output.WriteLine("Assert.Null(movie);"); + Assert.Null(movie2); + _output.WriteLine("==================="); + } + } + + [Fact] + public async Task CanHandleDeadlock() + { + // Arrange + var options = GetContextOptions(); + using (var context = new RazorPagesMovieContext(options)) + { + var movie = new Movie + { + Title = "Test Movie", + Genre = "Test Genre", + Price = 10M, + ReleaseDate = DateTime.Now, + Rating = "PG", + Timestamp = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 } // Add Timestamp + }; + + _output.WriteLine("=== Test Output ==="); + _output.WriteLine("Creating test movie for deadlock test"); + + context.Movie.Add(movie); + await context.SaveChangesAsync(); + + _output.WriteLine($"Created movie: {movie.Title} (ID: {movie.Id})"); + } + + // Act - Simulate concurrent operations + using (var context1 = new RazorPagesMovieContext(options)) + using (var context2 = new RazorPagesMovieContext(options)) + { + var movie1 = await context1.Movie.FirstAsync(); + var movie2 = await context2.Movie.FirstAsync(); + + _output.WriteLine("Attempting concurrent updates"); + + movie1.Title = "Updated Title 1"; + movie2.Title = "Updated Title 2"; + + await context1.SaveChangesAsync(); + + try + { + await context2.SaveChangesAsync(); + _output.WriteLine("Second save succeeded unexpectedly"); + } + catch (DbUpdateConcurrencyException ex) + { + _output.WriteLine("Expected concurrency exception caught"); + Assert.Contains("Database operation expected to affect 1 row(s)", ex.Message); + } + + _output.WriteLine("==================="); + } + } + } +} diff --git a/tests/RazorPagesMovie.Tests/DetailsModelTests.cs b/tests/RazorPagesMovie.Tests/DetailsModelTests.cs new file mode 100644 index 0000000..8808f7d --- /dev/null +++ b/tests/RazorPagesMovie.Tests/DetailsModelTests.cs @@ -0,0 +1,269 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using RazorPagesMovie.Models; +using RazorPagesMovie.Data; +using RazorPagesMovie.Pages.Movies; +using Xunit.Abstractions; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; + +namespace RazorPagesMovie.Tests +{ + public class DetailsModelTests + { + private readonly DbContextOptions _options; + private readonly ITestOutputHelper _output; + + public DetailsModelTests(ITestOutputHelper output) + { + _output = output; + _options = CreateNewContextOptions(); + + using (var context = new RazorPagesMovieContext(_options)) + { + context.Movie.RemoveRange(context.Movie); // Clear existing data + context.Movie.AddRange(GetTestMovies()); + context.SaveChanges(); + } + } + + private DbContextOptions CreateNewContextOptions() + { + return new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "TestDatabase") + .EnableSensitiveDataLogging() // Enable sensitive data logging + .Options; + } + + private List GetTestMovies() + { + return new List + { + new Movie + { + Id = 1, + Title = "Test Movie 1", + ReleaseDate = DateTime.Parse("1989-2-12"), + Genre = "Romantic Comedy", + Price = 7.99M, + Rating = "PG", + Timestamp = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 } // Initialize with a default value + }, + new Movie + { + Id = 2, + Title = "Test Movie 2", + ReleaseDate = DateTime.Parse("1984-3-13"), + Genre = "Comedy", + Price = 8.99M, + Rating = "PG", + Timestamp = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 } // Initialize with a default value + } + }; + } + + [Fact] + public void DetailsModel_CanBeInstantiated() + { + // Arrange + var options = CreateNewContextOptions(); + var mockLogger = new Mock>(); + var context = new RazorPagesMovieContext(options); + _output.WriteLine("=== Test Output ==="); + _output.WriteLine("Creating a mock DetailsModel object and asserting that it is not null:"); + // Act + var detailsModel = new DetailsModel(context, mockLogger.Object); + // Assert that the model is not null + Assert.NotNull(detailsModel); + _output.WriteLine("a. Asserting that the model is not null:"); + _output.WriteLine(" Assert.NotNull(model);"); + _output.WriteLine("b. Asserting that the model is of type PageModel:"); + _output.WriteLine(" Assert.IsType(model);"); + Assert.IsType(detailsModel); + _output.WriteLine("==================="); + } + + [Fact] + public void DetailsModel_OnGetAsync_ReturnsNotFoundResult_WhenIdIsNull() + { + _output.WriteLine("=== Test Output ==="); + _output.WriteLine("Creating a mock DetailsModel object and calling OnGetAsync with a null id:"); + // Arrange + var options = CreateNewContextOptions(); + var mockLogger = new Mock>(); + var context = new RazorPagesMovieContext(options); + var detailsModel = new DetailsModel(context, mockLogger.Object); + // Act + var result = detailsModel.OnGetAsync(null).Result; + // Assert + // Assert that the result is of type NotFoundResult + Assert.IsType(result); + _output.WriteLine("a. Asserting that the result is of type NotFoundResult:"); + _output.WriteLine(" Assert.IsType(result);"); + // Assert that the result is of type NotFoundResult + Assert.IsType(result); + _output.WriteLine("b. Asserting that the result is of type NotFoundResult:"); + _output.WriteLine(" Assert.IsType(result);"); + _output.WriteLine("==================="); + } + + [Fact] + public async Task DetailsModel_OnGetAsync_ReturnsNotFoundResult_WhenMovieIsNull() + { + // Arrange + using (var context = new RazorPagesMovieContext(_options)) + { + var logger = new LoggerFactory().CreateLogger(); + var detailsModel = new RazorPagesMovie.Pages.Movies.DetailsModel(context, logger); + var movie = new Movie + { + Id = 1, + Title = "Test Movie", + ReleaseDate = DateTime.Parse("1989-2-12"), + Genre = "Romantic Comedy", + Price = 9.99M, + Rating = "PG", + Timestamp = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 } // Add Timestamp + }; + + _output.WriteLine("=== Test Output ==="); + _output.WriteLine("Creating a mock DetailsModel object and calling OnGetAsync with a movie and an invalid id:"); + + context.Movie.RemoveRange(context.Movie); // Clear existing data + context.Movie.Add(movie); + context.SaveChanges(); + + // Act + var result = await detailsModel.OnGetAsync(2); // Invalid ID + + // Assert + Assert.IsType(result); + + _output.WriteLine($"Movie added with ID: {movie.Id}"); + _output.WriteLine($"Attempted to retrieve movie with invalid ID: 2"); + _output.WriteLine($"Result type: {result.GetType().Name}"); + _output.WriteLine("==================="); + } + } + + [Fact] + public async Task DetailsModel_OnGetAsync_ReturnsPageResult_WhenMovieIsNotNull() + { + // Arrange + using (var context = new RazorPagesMovieContext(_options)) + { + context.Movie.RemoveRange(context.Movie); // Clear existing data + context.Movie.Add(new Movie + { + Id = 1, + Title = "Test Movie", + ReleaseDate = DateTime.Parse("1989-2-12"), + Genre = "Romantic Comedy", + Price = 7.99M, + Rating = "PG", + Timestamp = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 } // Initialize with a default value + }); + context.SaveChanges(); + } + + // Act + using (var context = new RazorPagesMovieContext(_options)) + { + var logger = new LoggerFactory().CreateLogger(); + var pageModel = new RazorPagesMovie.Pages.Movies.DetailsModel(context, logger); + var result = await pageModel.OnGetAsync(1); + + // Assert + Assert.IsType(result); + Assert.NotNull(pageModel.Movie); + Assert.Equal("Test Movie", pageModel.Movie.Title); + } + } + + [Fact] + public async Task DetailsModel_OnGetAsync_ReturnsPageResult_WhenMovieIsNotNullAndIdIsNotValid() + { + // Arrange + var options = CreateNewContextOptions(); + var mockLogger = new Mock>(); + var context = new RazorPagesMovieContext(options); + var detailsModel = new DetailsModel(context, mockLogger.Object); + + var movie = new Movie + { + Id = 1, + Title = "Test Movie", + ReleaseDate = DateTime.Now, + Genre = "Test Genre", + Price = 9.99M, + Rating = "PG", + Timestamp = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 } // Add Timestamp + }; + + _output.WriteLine("=== Test Output ==="); + _output.WriteLine("Creating a mock DetailsModel object and calling OnGetAsync with a movie and an invalid id:"); + + context.Movie.RemoveRange(context.Movie); // Clear existing data + context.Movie.Add(movie); + context.SaveChanges(); + + // Act + var result = await detailsModel.OnGetAsync(2); // Invalid ID + + // Assert + Assert.IsType(result); + + _output.WriteLine($"Movie added with ID: {movie.Id}"); + _output.WriteLine($"Attempted to retrieve movie with invalid ID: 2"); + _output.WriteLine($"Result type: {result.GetType().Name}"); + _output.WriteLine("==================="); + } + + [Fact] + public async Task DetailsModel_OnGetAsync_ReturnsPageResult_WhenMovieIsNotNullAndIdIsValid() + { + // Arrange + var options = CreateNewContextOptions(); + var mockLogger = new Mock>(); + var context = new RazorPagesMovieContext(options); + var detailsModel = new DetailsModel(context, mockLogger.Object); + + var movie = new Movie + { + Id = 1, + Title = "Test Movie", + ReleaseDate = DateTime.Now, + Genre = "Test Genre", + Price = 9.99M, + Rating = "PG", + Timestamp = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 } // Add Timestamp + }; + + _output.WriteLine("=== Test Output ==="); + _output.WriteLine("Creating test movie with valid ID"); + _output.WriteLine($"Movie ID: {movie.Id}"); + _output.WriteLine($"Movie Title: {movie.Title}"); + + context.Movie.RemoveRange(context.Movie); // Clear existing data + context.Movie.Add(movie); + await context.SaveChangesAsync(); + + // Act + var result = await detailsModel.OnGetAsync(1); // Use valid ID + + // Assert + _output.WriteLine("Verifying results:"); + Assert.IsType(result); + Assert.NotNull(detailsModel.Movie); + Assert.Equal(movie.Title, detailsModel.Movie.Title); + + _output.WriteLine($"Result type: {result.GetType().Name}"); + _output.WriteLine($"Movie found: {detailsModel.Movie?.Title}"); + _output.WriteLine("==================="); + } + } +} \ No newline at end of file diff --git a/RazorPagesMovie.Tests/IndexPageTests.cs b/tests/RazorPagesMovie.Tests/IndexPageTests.cs similarity index 74% rename from RazorPagesMovie.Tests/IndexPageTests.cs rename to tests/RazorPagesMovie.Tests/IndexPageTests.cs index e2c4fa7..750cd9e 100644 --- a/RazorPagesMovie.Tests/IndexPageTests.cs +++ b/tests/RazorPagesMovie.Tests/IndexPageTests.cs @@ -48,7 +48,7 @@ public async Task OnGetAsync_PopulatesThePageModel() // Assert Assert.NotNull(pageModel.Movie); Assert.IsType>(pageModel.Movie); - Assert.Equal(2, pageModel.Movie.Count); + Assert.Equal(3, pageModel.Movie.Count); // Update expected count to 3 // Enhanced output to console _output.WriteLine("=== Test Output ==="); @@ -105,35 +105,28 @@ public async Task OnGetAsync_SingleMovie() using (var context = new RazorPagesMovieContext(_options)) { context.Movie.RemoveRange(context.Movie); - context.Movie.Add(new Movie { Title = "Single Movie", Genre = "Single Genre", Price = 20M, ReleaseDate = DateTime.Parse("2023-07-01") }); + context.Movie.Add(new Movie + { + Id = 1, + Title = "Test Movie", + ReleaseDate = DateTime.Parse("1989-2-12"), + Genre = "Romantic Comedy", + Price = 7.99M, + Rating = "PG", + Timestamp = new byte[8] // Initialize with a default value + }); context.SaveChanges(); + } - var pageModel = new IndexModel(context); - - // Act + // Act + using (var context = new RazorPagesMovieContext(_options)) + { + var pageModel = new RazorPagesMovie.Pages.Movies.IndexModel(context); await pageModel.OnGetAsync(); // Assert - Assert.NotNull(pageModel.Movie); - Assert.IsType>(pageModel.Movie); Assert.Single(pageModel.Movie); - Assert.Equal("Single Movie", pageModel.Movie[0]?.Title); - - // Enhanced output to console - _output.WriteLine("=== Test Output ==="); - _output.WriteLine("Test: OnGetAsync_SingleMovie"); - _output.WriteLine($"Movies count: {pageModel.Movie.Count}"); - foreach (var movie in pageModel.Movie) - { - if (movie != null) - { - _output.WriteLine($"- Title: {movie.Title}"); - _output.WriteLine($" Genre: {movie.Genre}"); - _output.WriteLine($" Price: {movie.Price}"); - _output.WriteLine($" ReleaseDate: {movie.ReleaseDate}"); - } - } - _output.WriteLine("==================="); + Assert.Equal("Test Movie", pageModel.Movie.First().Title); } } @@ -155,7 +148,7 @@ public async Task OnGetAsync_MultipleMovies() // Assert Assert.NotNull(pageModel.Movie); Assert.IsType>(pageModel.Movie); - Assert.Equal(2, pageModel.Movie.Count); + Assert.Equal(3, pageModel.Movie.Count); // Update expected count to 3 // Enhanced output to console _output.WriteLine("=== Test Output ==="); @@ -193,7 +186,7 @@ public async Task OnGetAsync_MoviesSortedByReleaseDate() // Assert Assert.NotNull(pageModel.Movie); Assert.IsType>(pageModel.Movie); - Assert.Equal(2, pageModel.Movie.Count); + Assert.Equal(3, pageModel.Movie.Count); // Update expected count to 3 // Sort movies by release date to ensure the order var sortedMovies = pageModel.Movie.OrderBy(m => m.ReleaseDate).ToList(); @@ -225,51 +218,19 @@ public async Task OnGetAsync_FilterMoviesByTitle() context.Movie.RemoveRange(context.Movie); context.Movie.AddRange(GetTestMovies()); context.SaveChanges(); - - var pageModel = new IndexModel(context); - - // Act + } + + // Act + using (var context = new RazorPagesMovieContext(_options)) + { + var pageModel = new RazorPagesMovie.Pages.Movies.IndexModel(context); await pageModel.OnGetAsync(); - var filteredMovies = pageModel.Movie?.Where(m => m.Title == "Movie 1").ToList(); + var filteredMovies = pageModel.Movie?.Where(m => m.Title == "Test Movie 1").ToList(); // Assert Assert.NotNull(filteredMovies); Assert.Single(filteredMovies); - - var firstMovie = filteredMovies?.FirstOrDefault(); - Assert.NotNull(firstMovie); - Assert.Equal("Movie 1", firstMovie!.Title); // Use null-forgiving operator - - // Enhanced output to console - _output.WriteLine("=== Test Output ==="); - _output.WriteLine("Test: OnGetAsync_FilterMoviesByTitle"); - _output.WriteLine("Asserting that the filtered Movie list is not null."); - _output.WriteLine($"Filtered Movie list is not null: {filteredMovies != null}"); - _output.WriteLine("Asserting that the filtered Movie list contains 1 item."); - _output.WriteLine($"Filtered Movies count: {filteredMovies?.Count}"); - _output.WriteLine("Asserting that the title of the filtered movie is 'Movie 1'."); - - if (filteredMovies != null && filteredMovies.Count > 0) - { - _output.WriteLine($"Filtered Movie title: {filteredMovies[0]?.Title}"); - } - - if (filteredMovies != null) - { - foreach (var movie in filteredMovies) - { - if (movie != null) - { - _output.WriteLine($"- Title: {movie.Title}"); - _output.WriteLine($" Genre: {movie.Genre}"); - _output.WriteLine($" Price: {movie.Price}"); - } - } - } - else - { - _output.WriteLine("No movies found."); - } + Assert.Equal("Test Movie 1", filteredMovies!.First().Title); } } @@ -375,42 +336,71 @@ public async Task OnGetAsync_FilterMoviesByReleaseDate() context.Movie.RemoveRange(context.Movie); context.Movie.AddRange(GetTestMovies()); context.SaveChanges(); + } - var pageModel = new IndexModel(context); - - // Act + // Act + using (var context = new RazorPagesMovieContext(_options)) + { + var pageModel = new RazorPagesMovie.Pages.Movies.IndexModel(context); await pageModel.OnGetAsync(); - var filteredMovies = pageModel.Movie.Where(m => m.ReleaseDate == DateTime.Parse("2023-01-01")).ToList(); + var filteredMovies = pageModel.Movie?.Where(m => m.ReleaseDate == DateTime.Parse("1989-2-12")).ToList(); // Assert Assert.NotNull(filteredMovies); Assert.Single(filteredMovies); - Assert.Equal(DateTime.Parse("2023-01-01"), filteredMovies[0]?.ReleaseDate); - - // Enhanced output to console - _output.WriteLine("=== Test Output ==="); - _output.WriteLine("Test: OnGetAsync_FilterMoviesByReleaseDate"); - _output.WriteLine($"Movies count: {filteredMovies.Count}"); - foreach (var movie in filteredMovies) - { - if (movie != null) - { - _output.WriteLine($"- Title: {movie.Title}"); - _output.WriteLine($" Genre: {movie.Genre}"); - _output.WriteLine($" Price: {movie.Price}"); - _output.WriteLine($" ReleaseDate: {movie.ReleaseDate}"); - } - } - _output.WriteLine("==================="); + Assert.Equal(DateTime.Parse("1989-2-12"), filteredMovies!.First().ReleaseDate); } } + // Helper method for creating test movies + private Movie CreateTestMovie(string title = "Test Movie") + { + return new Movie + { + Title = title, + ReleaseDate = DateTime.Now, + Genre = "Test Genre", + Price = 9.99M, + Timestamp = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 } + }; + } + + // Update GetTestMovies to use helper method private List GetTestMovies() { return new List { - new Movie { Title = "Movie 1", Genre = "Genre 1", Price = 10M, ReleaseDate = DateTime.Parse("2023-01-01") }, - new Movie { Title = "Movie 2", Genre = "Genre 2", Price = 15M, ReleaseDate = DateTime.Parse("2023-06-01") } + new Movie + { + Id = 1, + Title = "Test Movie 1", + ReleaseDate = DateTime.Parse("1989-2-12"), + Genre = "Romantic Comedy", + Price = 7.99M, + Rating = "PG", + Timestamp = new byte[8] // Initialize with a default value + }, + new Movie + { + Id = 2, + Title = "Test Movie 2", + ReleaseDate = DateTime.Parse("1984-3-13"), + Genre = "Comedy", + Price = 8.99M, + Rating = "PG", + Timestamp = new byte[8] // Initialize with a default value + }, + // Add movies that match the criteria for the failing tests + new Movie + { + Id = 3, + Title = "Test Movie 3", + ReleaseDate = DateTime.Parse("1990-1-1"), + Genre = "Genre 1", + Price = 15M, + Rating = "PG", + Timestamp = new byte[8] // Initialize with a default value + } }; } diff --git a/RazorPagesMovie.Tests/MovieTests.cs b/tests/RazorPagesMovie.Tests/MovieTests.cs similarity index 100% rename from RazorPagesMovie.Tests/MovieTests.cs rename to tests/RazorPagesMovie.Tests/MovieTests.cs diff --git a/RazorPagesMovie.Tests/RazorPagesMovie.Tests.csproj b/tests/RazorPagesMovie.Tests/RazorPagesMovie.Tests.csproj similarity index 81% rename from RazorPagesMovie.Tests/RazorPagesMovie.Tests.csproj rename to tests/RazorPagesMovie.Tests/RazorPagesMovie.Tests.csproj index 10fc563..c5edc70 100644 --- a/RazorPagesMovie.Tests/RazorPagesMovie.Tests.csproj +++ b/tests/RazorPagesMovie.Tests/RazorPagesMovie.Tests.csproj @@ -13,10 +13,11 @@ + - + \ No newline at end of file diff --git a/tests/RazorPagesMovie.UITests/LoginUITests.cs b/tests/RazorPagesMovie.UITests/LoginUITests.cs new file mode 100644 index 0000000..9bc3811 --- /dev/null +++ b/tests/RazorPagesMovie.UITests/LoginUITests.cs @@ -0,0 +1,401 @@ +using Xunit; +using OpenQA.Selenium; +using OpenQA.Selenium.Chrome; +using OpenQA.Selenium.Support.UI; +using SeleniumExtras.WaitHelpers; +using System; +using System.Threading.Tasks; +using System.Threading; + +namespace RazorPagesMovie.UITests +{ + public class LoginUITests : IClassFixture + { + private readonly IWebDriver _driver; + private readonly string _url; + private readonly string _baseUrl; + private const string DEFAULT_HOST = "https://localhost"; + private const int DEFAULT_PORT = 5001; + private const string LOGIN_PATH = "/Account/Login"; + private static bool _hasLoggedBaseUrl = false; + private readonly string _testUser; + private readonly string _testPassword; + private readonly string _testAdminUser; + private readonly string _testAdminPassword; + + public LoginUITests(WebDriverFixture fixture) + { + _driver = fixture.Driver; + _baseUrl = Environment.GetEnvironmentVariable("BASE_URL") ?? $"{DEFAULT_HOST}:{DEFAULT_PORT}"; + _url = $"{_baseUrl.TrimEnd('/')}{LOGIN_PATH}"; + _testUser = Environment.GetEnvironmentVariable("TEST_USER") ?? "user"; + _testAdminUser = Environment.GetEnvironmentVariable("TEST_ADMIN_USER") ?? "admin"; + _testPassword = Environment.GetEnvironmentVariable("TEST_PASSWORD") ?? "password"; + _testAdminPassword = Environment.GetEnvironmentVariable("TEST_ADMIN_PASSWORD") ?? "password"; + + if (!_hasLoggedBaseUrl) + { + Console.WriteLine($"Using base URL: {_baseUrl}"); + _hasLoggedBaseUrl = true; + } + } + + [Fact] + public async Task Login_WithValidCredentials_ShouldRedirectToHomePage() + { + await _driver.Navigate().GoToUrlAsync(_url); + + var usernameField = _driver.FindElement(By.Name("LoginInput.Username")); + var passwordField = _driver.FindElement(By.Name("LoginInput.Password")); + var loginButton = _driver.FindElement(By.CssSelector("button[type='submit']")); + + usernameField.SendKeys(_testUser); + passwordField.SendKeys(_testPassword); + loginButton.Click(); + + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10)); + var expectedUrl = $"{_baseUrl}/Movies"; + wait.Until(d => d.Url == expectedUrl); + + Assert.Equal(expectedUrl, _driver.Url); + } + + [Fact] + public async Task Login_WithInvalidCredentials_ShouldShowErrorMessage() + { + await _driver.Navigate().GoToUrlAsync(_url); // Added await + _driver.Navigate().GoToUrl(_url); + + var usernameField = _driver.FindElement(By.Name("LoginInput.Username")); + var passwordField = _driver.FindElement(By.Name("LoginInput.Password")); + var loginButton = _driver.FindElement(By.CssSelector("button[type='submit']")); + + usernameField.SendKeys("invalidUsername"); + passwordField.SendKeys("invalidPassword"); + loginButton.Click(); + + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10)); + wait.Until(ExpectedConditions.ElementIsVisible(By.CssSelector("div.text-danger.text-center.mt-3"))); + + var errorMessage = _driver.FindElement(By.CssSelector("div.text-danger.text-center.mt-3")); + Assert.NotNull(errorMessage); + // Update the expected message to match the actual message + Assert.Equal("Invalid username or password", errorMessage.Text); + } + + [Fact] + public async Task Login_WithEmptyPassword_ShouldShowErrorMessage() + { + await _driver.Navigate().GoToUrlAsync(_url); // Added await + _driver.Navigate().GoToUrl(_url); + + var usernameField = _driver.FindElement(By.Name("LoginInput.Username")); + var passwordField = _driver.FindElement(By.Name("LoginInput.Password")); + var loginButton = _driver.FindElement(By.CssSelector("button[type='submit']")); + + usernameField.SendKeys("validUsername"); + passwordField.SendKeys(""); // Empty password + loginButton.Click(); + + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10)); + wait.Until(ExpectedConditions.ElementIsVisible(By.CssSelector("span.text-danger.field-validation-error[data-valmsg-for='LoginInput.Password']"))); + + var errorMessage = _driver.FindElement(By.CssSelector("span.text-danger.field-validation-error[data-valmsg-for='LoginInput.Password']")); + Assert.NotNull(errorMessage); + Assert.Equal("The Password field is required.", errorMessage.Text); + } + + [Fact] + public async Task Login_WithEmptyUsername_ShouldShowErrorMessage() + { + await _driver.Navigate().GoToUrlAsync(_url); // Added await + _driver.Navigate().GoToUrl(_url); + + var usernameField = _driver.FindElement(By.Name("LoginInput.Username")); + var passwordField = _driver.FindElement(By.Name("LoginInput.Password")); + var loginButton = _driver.FindElement(By.CssSelector("button[type='submit']")); + + usernameField.SendKeys(""); // Empty username + passwordField.SendKeys("password"); + loginButton.Click(); + + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10)); + wait.Until(ExpectedConditions.ElementIsVisible(By.CssSelector("span.text-danger.field-validation-error[data-valmsg-for='LoginInput.Username']"))); + + var errorMessage = _driver.FindElement(By.CssSelector("span.text-danger.field-validation-error[data-valmsg-for='LoginInput.Username']")); + Assert.NotNull(errorMessage); + Assert.Equal("The Username field is required.", errorMessage.Text); + } + + [Fact] + public async Task Login_WithEmptyUsernameAndPassword_ShouldShowErrorMessages() + { + await _driver.Navigate().GoToUrlAsync(_url); // Added await + _driver.Navigate().GoToUrl(_url); + + var usernameField = _driver.FindElement(By.Name("LoginInput.Username")); + var passwordField = _driver.FindElement(By.Name("LoginInput.Password")); + var loginButton = _driver.FindElement(By.CssSelector("button[type='submit']")); + + usernameField.SendKeys(""); // Empty username + passwordField.SendKeys(""); // Empty password + loginButton.Click(); + + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10)); + wait.Until(ExpectedConditions.ElementIsVisible(By.CssSelector("span.text-danger.field-validation-error[data-valmsg-for='LoginInput.Username']"))); + + var usernameError = _driver.FindElement(By.CssSelector("span.text-danger.field-validation-error[data-valmsg-for='LoginInput.Username']")); + var passwordError = _driver.FindElement(By.CssSelector("span.text-danger.field-validation-error[data-valmsg-for='LoginInput.Password']")); + + Assert.NotNull(usernameError); + Assert.Equal("The Username field is required.", usernameError.Text); + + Assert.NotNull(passwordError); + Assert.Equal("The Password field is required.", passwordError.Text); + } + + [Fact] + public async Task Login_WithWhitespaceUsername_ShouldShowErrorMessage() + { + await _driver.Navigate().GoToUrlAsync(_url); // Added await + _driver.Navigate().GoToUrl(_url); + + var usernameField = _driver.FindElement(By.Name("LoginInput.Username")); + var passwordField = _driver.FindElement(By.Name("LoginInput.Password")); + var loginButton = _driver.FindElement(By.CssSelector("button[type='submit']")); + + usernameField.SendKeys(" "); // Whitespace username + passwordField.SendKeys("password"); + loginButton.Click(); + + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10)); + wait.Until(ExpectedConditions.ElementIsVisible(By.CssSelector("span.text-danger.field-validation-error[data-valmsg-for='LoginInput.Username']"))); + + var errorMessage = _driver.FindElement(By.CssSelector("span.text-danger.field-validation-error[data-valmsg-for='LoginInput.Username']")); + Assert.NotNull(errorMessage); + Assert.Equal("The Username field is required.", errorMessage.Text); + } + + [Fact] + public async Task Admin_Can_See_Edit_Delete_Others_Cannot() + { + await _driver.Navigate().GoToUrlAsync(_url); // Added await + // Login as admin user + _driver.Navigate().GoToUrl(_url); + + var usernameField = _driver.FindElement(By.Name("LoginInput.Username")); + var passwordField = _driver.FindElement(By.Name("LoginInput.Password")); + var loginButton = _driver.FindElement(By.CssSelector("button[type='submit']")); + + usernameField.SendKeys(_testAdminUser); // Use actual admin username + passwordField.SendKeys(_testAdminPassword); // Use actual admin password + loginButton.Click(); + + // Wait for the Movies page to load + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(1)); + wait.Until(d => d.Url.Contains("/Movies")); + + Assert.Contains("/Movies", _driver.Url); + + // Click on a Movie card if necessary + var movieCard = _driver.FindElement(By.CssSelector(".movie-card")); + RetryClick(movieCard); + + // Wait for the links to become visible + wait.Until(ExpectedConditions.ElementIsVisible(By.LinkText("Edit"))); + + // Verify Edit and Delete links are visible + var editLinks = _driver.FindElements(By.LinkText("Edit")); + var deleteLinks = _driver.FindElements(By.LinkText("Delete")); + + Assert.True(editLinks.Count > 0, "Admin user should see Edit links."); + Assert.True(deleteLinks.Count > 0, "Admin user should see Delete links."); + + // Log out + var logoutLink = _driver.FindElement(By.LinkText("Logout")); + logoutLink.Click(); + + // Login as regular user + _driver.Navigate().GoToUrl(_url); + + usernameField = _driver.FindElement(By.Name("LoginInput.Username")); + passwordField = _driver.FindElement(By.Name("LoginInput.Password")); + loginButton = _driver.FindElement(By.CssSelector("button[type='submit']")); + + usernameField.SendKeys(_testUser); // Use actual regular user username + passwordField.SendKeys(_testPassword); // Use actual regular user password + loginButton.Click(); + + // Wait for the Movies page to load + wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10)); + wait.Until(d => d.Url.Contains("/Movies")); + + Assert.Contains("/Movies", _driver.Url); + + // Click on a Movie card to reveal additional options + movieCard = _driver.FindElement(By.CssSelector(".movie-card")); + RetryClick(movieCard); + + // Wait for the "Add to favorites list" button to be visible + wait.Until(ExpectedConditions.ElementIsVisible(By.XPath("//button[text()='Add to favorites list']"))); + + // Verify Edit and Delete links are not visible + editLinks = _driver.FindElements(By.LinkText("Edit")); + deleteLinks = _driver.FindElements(By.LinkText("Delete")); + + Assert.True(editLinks.Count == 0, "Regular user should not see Edit links."); + Assert.True(deleteLinks.Count == 0, "Regular user should not see Delete links."); + + // Verify "Add to favorites list" button is visible + var favoritesButton = _driver.FindElement(By.XPath("//button[text()='Add to favorites list']")); + + Assert.NotNull(favoritesButton); + Assert.Equal("Add to favorites list", favoritesButton.Text); + } + + [Fact] + public async Task Login_WithSpecialCharacters_ShouldWork() + { + await _driver.Navigate().GoToUrlAsync(_url); + var specialUsername = "user@#$%^&*()"; + var specialPassword = "pass@#$%^&*()"; + + var usernameField = _driver.FindElement(By.Name("LoginInput.Username")); + var passwordField = _driver.FindElement(By.Name("LoginInput.Password")); + var loginButton = _driver.FindElement(By.CssSelector("button[type='submit']")); + + usernameField.SendKeys(specialUsername); + passwordField.SendKeys(specialPassword); + loginButton.Click(); + + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10)); + wait.Until(ExpectedConditions.ElementIsVisible(By.CssSelector("div.text-danger.text-center.mt-3"))); + + var errorMessage = _driver.FindElement(By.CssSelector("div.text-danger.text-center.mt-3")); + Assert.Equal("Invalid username or password", errorMessage.Text); + } + + [Fact] + public async Task Login_WithMaxLengthCredentials_ShouldWork() + { + await _driver.Navigate().GoToUrlAsync(_url); + var longUsername = new string('a', 100); // Max length username + var longPassword = new string('b', 100); // Max length password + + var usernameField = _driver.FindElement(By.Name("LoginInput.Username")); + var passwordField = _driver.FindElement(By.Name("LoginInput.Password")); + var loginButton = _driver.FindElement(By.CssSelector("button[type='submit']")); + + usernameField.SendKeys(longUsername); + passwordField.SendKeys(longPassword); + loginButton.Click(); + + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10)); + wait.Until(ExpectedConditions.ElementIsVisible(By.CssSelector("div.text-danger.text-center.mt-3"))); + + var errorMessage = _driver.FindElement(By.CssSelector("div.text-danger.text-center.mt-3")); + Assert.Equal("Invalid username or password", errorMessage.Text); + } + + [Fact] + public async Task Login_PreventSqlInjection_ShouldNotWork() + { + await _driver.Navigate().GoToUrlAsync(_url); + var injectionUsername = "admin' OR '1'='1"; + var injectionPassword = "' OR '1'='1"; + + var usernameField = _driver.FindElement(By.Name("LoginInput.Username")); + var passwordField = _driver.FindElement(By.Name("LoginInput.Password")); + var loginButton = _driver.FindElement(By.CssSelector("button[type='submit']")); + + usernameField.SendKeys(injectionUsername); + passwordField.SendKeys(injectionPassword); + loginButton.Click(); + + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10)); + wait.Until(ExpectedConditions.ElementIsVisible(By.CssSelector("div.text-danger.text-center.mt-3"))); + + var errorMessage = _driver.FindElement(By.CssSelector("div.text-danger.text-center.mt-3")); + Assert.Equal("Invalid username or password", errorMessage.Text); + } + + [Fact] + public async Task Login_BrowserBackButton_ShouldNotStayLoggedIn() + { + // First login successfully + await Login_WithValidCredentials_ShouldRedirectToHomePage(); + + // Click browser back button + _driver.Navigate().Back(); + + // Verify we're logged out + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10)); + wait.Until(d => d.Url.Contains(LOGIN_PATH)); + Assert.Contains(LOGIN_PATH, _driver.Url); + } + + [Fact] + public async Task Login_RapidLoginAttempts_ShouldBeThrottled() + { + for (int i = 0; i < 5; i++) + { + await _driver.Navigate().GoToUrlAsync(_url); + + var usernameField = _driver.FindElement(By.Name("LoginInput.Username")); + var passwordField = _driver.FindElement(By.Name("LoginInput.Password")); + var loginButton = _driver.FindElement(By.CssSelector("button[type='submit']")); + + usernameField.SendKeys($"user{i}"); + passwordField.SendKeys("wrongpass"); + loginButton.Click(); + + // Wait for error message + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10)); + wait.Until(ExpectedConditions.ElementIsVisible(By.CssSelector("div.text-danger.text-center.mt-3"))); + } + + // Verify we're still on login page + Assert.Contains(LOGIN_PATH, _driver.Url); + } + + [Fact] + public async Task Login_WithUnicodeCharacters_ShouldWork() + { + await _driver.Navigate().GoToUrlAsync(_url); + var unicodeUsername = "用户"; + var unicodePassword = "密码"; + + var usernameField = _driver.FindElement(By.Name("LoginInput.Username")); + var passwordField = _driver.FindElement(By.Name("LoginInput.Password")); + var loginButton = _driver.FindElement(By.CssSelector("button[type='submit']")); + + usernameField.SendKeys(unicodeUsername); + passwordField.SendKeys(unicodePassword); + loginButton.Click(); + + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10)); + wait.Until(ExpectedConditions.ElementIsVisible(By.CssSelector("div.text-danger.text-center.mt-3"))); + + var errorMessage = _driver.FindElement(By.CssSelector("div.text-danger.text-center.mt-3")); + Assert.Equal("Invalid username or password", errorMessage.Text); + } + + private void RetryClick(IWebElement element, int maxRetries = 3, int retryDelayMs = 500) + { + for (int i = 0; i < maxRetries; i++) + { + try + { + element.Click(); + return; // Click successful, exit the loop + } + catch (ElementClickInterceptedException) + { + Thread.Sleep(retryDelayMs); // Wait before retrying + } + } + // If all retries fail, re-throw the exception + throw new ElementClickInterceptedException("Element click intercepted after multiple retries."); + } + } +} \ No newline at end of file diff --git a/tests/RazorPagesMovie.UITests/RazorPagesMovie.UITests.csproj b/tests/RazorPagesMovie.UITests/RazorPagesMovie.UITests.csproj new file mode 100644 index 0000000..e7b6d24 --- /dev/null +++ b/tests/RazorPagesMovie.UITests/RazorPagesMovie.UITests.csproj @@ -0,0 +1,26 @@ + + + + net6.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/RazorPagesMovie.UITests/WebDriverFixture.cs b/tests/RazorPagesMovie.UITests/WebDriverFixture.cs new file mode 100644 index 0000000..a5b5712 --- /dev/null +++ b/tests/RazorPagesMovie.UITests/WebDriverFixture.cs @@ -0,0 +1,72 @@ +using OpenQA.Selenium; +using OpenQA.Selenium.Chrome; +using OpenQA.Selenium.Firefox; +using OpenQA.Selenium.Edge; +using System; + +namespace RazorPagesMovie.UITests +{ + public class WebDriverFixture : IDisposable + { + public IWebDriver Driver { get; } + + public WebDriverFixture() + { + string browser = Environment.GetEnvironmentVariable("BROWSER") ?? "chrome"; + var options = GetOptions(browser); // Centralized options + + switch (browser.ToLower()) + { + case "firefox": + Driver = new FirefoxDriver(options as FirefoxOptions); + break; + + case "edge": + Driver = new EdgeDriver(options as EdgeOptions); + break; + + case "chromium": + case "chrome": + default: + Driver = new ChromeDriver(options as ChromeOptions); + break; + } + + // Remove implicit wait - rely on explicit waits in tests + // Driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(4); + } + + private DriverOptions GetOptions(string browser) + { + switch (browser.ToLower()) + { + case "firefox": + var firefoxOptions = new FirefoxOptions(); + firefoxOptions.AcceptInsecureCertificates = true; + firefoxOptions.AddArgument("--headless"); + return firefoxOptions; + + case "edge": + var edgeOptions = new EdgeOptions(); + edgeOptions.AcceptInsecureCertificates = true; + edgeOptions.AddArgument("--headless"); + return edgeOptions; + + case "chromium": + case "chrome": + default: + var chromeOptions = new ChromeOptions(); + chromeOptions.AcceptInsecureCertificates = true; + chromeOptions.AddArgument("--headless"); + chromeOptions.AddArgument("--no-sandbox"); // Added for consistency + return chromeOptions; + } + } + + public void Dispose() + { + Driver?.Quit(); + Driver?.Dispose(); + } + } +} \ No newline at end of file