├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .editorconfig ├── .gitattributes ├── .github ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── 10_bug_report.yml │ ├── 20_feature_request.yml │ ├── 30_question.yml │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md ├── actionlint-matcher.json ├── dependabot.yml └── workflows │ ├── build.yml │ ├── codeql.yml │ ├── dependency-review.yml │ └── lint.yml ├── .gitignore ├── .markdownlint.json ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── .vsconfig ├── CODE_OF_CONDUCT.md ├── Directory.Build.props ├── LICENSE ├── NuGet.config ├── README.md ├── SECURITY.md ├── TodoApp.ruleset ├── TodoApp.slnx ├── build.ps1 ├── global.json ├── omnisharp.json ├── src └── TodoApp │ ├── .npmrc │ ├── .prettierignore │ ├── ApiEndpoints.cs │ ├── AuthenticationEndpoints.cs │ ├── Data │ ├── ITodoRepository.cs │ ├── TodoContext.cs │ ├── TodoItem.cs │ └── TodoRepository.cs │ ├── Extensions │ └── DbSetExtensions.cs │ ├── Models │ ├── CreateTodoItemModel.cs │ ├── CreatedTodoItemModel.cs │ ├── TodoItemModel.cs │ └── TodoListViewModel.cs │ ├── Pages │ ├── Home │ │ ├── Index.cshtml │ │ └── Index.cshtml.cs │ ├── Shared │ │ ├── Error.cshtml │ │ ├── Error.cshtml.cs │ │ └── _Layout.cshtml │ ├── _ViewImports.cshtml │ └── _ViewStart.cshtml │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ ├── Services │ ├── ITodoService.cs │ └── TodoService.cs │ ├── TodoApp.csproj │ ├── appsettings.Development.json │ ├── appsettings.json │ ├── eslint.config.js │ ├── package-lock.json │ ├── package.json │ ├── scripts │ └── ts │ │ ├── client │ │ └── TodoClient.ts │ │ ├── main.ts │ │ ├── models │ │ ├── TodoItem.ts │ │ └── TodoList.ts │ │ └── view │ │ ├── Classes.ts │ │ ├── Elements.ts │ │ ├── Selectors.ts │ │ ├── TodoApp.test.ts │ │ ├── TodoApp.ts │ │ └── TodoElement.ts │ ├── tsconfig.json │ ├── webpack.config.cjs │ └── wwwroot │ ├── css │ └── site.css │ ├── favicon.ico │ └── swagger-ui │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ └── index.html ├── startvs.cmd ├── startvscode.cmd └── tests └── TodoApp.Tests ├── ApiTests.cs ├── BrowserFixture.cs ├── BrowserFixtureOptions.cs ├── BrowserStackLocalOptions.cs ├── BrowserStackLocalService.cs ├── HttpRequestInterceptionFilter.cs ├── HttpServerCollection.cs ├── HttpServerFixture.cs ├── LoopbackOAuthEvents.cs ├── RemoteAuthorizationEventsFilter.cs ├── ShouldlyTaskExtensions.cs ├── TodoApp.Tests.csproj ├── TodoAppCollection.cs ├── TodoAppFixture.cs ├── TodoPage.cs ├── UITests.cs ├── localhost-dev.pfx ├── oauth-http-bundle.json └── xunit.runner.json /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/vscode/devcontainers/universal:latest 2 | 3 | # Suppress an apt-key warning about standard out not being a terminal. Use in this script is safe. 4 | ENV APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn 5 | 6 | # Install Google Chrome 7 | RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add - 8 | RUN echo 'deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main' | sudo tee /etc/apt/sources.list.d/google-chrome.list 9 | RUN sudo apt-get update 10 | RUN sudo apt-get --yes install google-chrome-stable 2>&1 11 | 12 | # Install Firefox 13 | RUN sudo apt-get --yes install firefox 2>&1 14 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "C# (.NET)", 3 | "build": { 4 | "dockerfile": "Dockerfile" 5 | }, 6 | "customizations": { 7 | "vscode": { 8 | "extensions": [ 9 | "editorconfig.editorconfig", 10 | "ms-dotnettools.csharp", 11 | "ms-vscode.PowerShell" 12 | ] 13 | } 14 | }, 15 | "forwardPorts": [ 50000, 50001 ], 16 | "portsAttributes":{ 17 | "50000": { 18 | "onAutoForward": "silent" 19 | }, 20 | "50001": { 21 | "label": "TodoApp", 22 | "onAutoForward": "openBrowserOnce" 23 | } 24 | }, 25 | "postCreateCommand": "./build.ps1 -SkipTests", 26 | "remoteEnv": { 27 | "DOTNET_ROLL_FORWARD": "Major", 28 | "PATH": "/root/.dotnet/tools:${containerWorkspaceFolder}/.dotnet:${containerEnv:PATH}" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = crlf 7 | indent_size = 4 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.{config,csproj,json,props,ruleset,slnx,targets,yml}] 13 | indent_size = 2 14 | 15 | # Code files 16 | [*.{cs,csx,vb,vbx}] 17 | indent_size = 4 18 | insert_final_newline = true 19 | charset = utf-8-bom 20 | 21 | ############################### 22 | # .NET Coding Conventions # 23 | ############################### 24 | 25 | [*.{cs,vb}] 26 | 27 | dotnet_diagnostic.IDE0058.severity = silent 28 | 29 | # Organize usings 30 | dotnet_sort_system_directives_first = true 31 | 32 | # this. preferences 33 | dotnet_style_qualification_for_field = false:none 34 | dotnet_style_qualification_for_property = false:none 35 | dotnet_style_qualification_for_method = false:none 36 | dotnet_style_qualification_for_event = false:none 37 | 38 | # Language keywords vs BCL types preferences 39 | dotnet_style_predefined_type_for_locals_parameters_members = true:none 40 | dotnet_style_predefined_type_for_member_access = true:none 41 | 42 | # Modifier preferences 43 | dotnet_style_require_accessibility_modifiers = for_non_interface_members:none 44 | dotnet_style_readonly_field = true:suggestion 45 | 46 | # Expression-level preferences 47 | dotnet_style_object_initializer = true:suggestion 48 | dotnet_style_collection_initializer = true:suggestion 49 | dotnet_style_explicit_tuple_names = true:suggestion 50 | dotnet_style_null_propagation = true:suggestion 51 | dotnet_style_coalesce_expression = true:suggestion 52 | dotnet_style_prefer_is_null_check_over_reference_equality_method = true:none 53 | dotnet_style_prefer_inferred_tuple_names = true:suggestion 54 | dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion 55 | dotnet_style_prefer_auto_properties = true:none 56 | 57 | ############################### 58 | # Naming Conventions # 59 | ############################### 60 | 61 | # Style Definitions 62 | dotnet_naming_style.pascal_case_style.capitalization = pascal_case 63 | 64 | # Use PascalCase for constant fields 65 | dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion 66 | dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields 67 | dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style 68 | dotnet_naming_symbols.constant_fields.applicable_kinds = field 69 | dotnet_naming_symbols.constant_fields.applicable_accessibilities = * 70 | dotnet_naming_symbols.constant_fields.required_modifiers = const 71 | 72 | ############################### 73 | # C# Coding Conventions # 74 | ############################### 75 | [*.cs] 76 | # var preferences 77 | csharp_style_var_for_built_in_types = true:none 78 | csharp_style_var_when_type_is_apparent = true:none 79 | csharp_style_var_elsewhere = true:none 80 | 81 | # Expression-bodied members 82 | csharp_style_expression_bodied_methods = false:none 83 | csharp_style_expression_bodied_constructors = false:none 84 | csharp_style_expression_bodied_operators = false:none 85 | csharp_style_expression_bodied_properties = true:none 86 | csharp_style_expression_bodied_indexers = true:none 87 | csharp_style_expression_bodied_accessors = true:none 88 | 89 | # Pattern matching preferences 90 | csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion 91 | csharp_style_pattern_matching_over_as_with_null_check = true:suggestion 92 | 93 | # Null-checking preferences 94 | csharp_style_throw_expression = true:suggestion 95 | csharp_style_conditional_delegate_call = true:suggestion 96 | 97 | # Modifier preferences 98 | csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion 99 | 100 | # Expression-level preferences 101 | csharp_prefer_braces = true:none 102 | csharp_style_deconstructed_variable_declaration = true:suggestion 103 | csharp_prefer_simple_default_expression = true:suggestion 104 | csharp_style_pattern_local_over_anonymous_function = true:suggestion 105 | csharp_style_inlined_variable_declaration = true:suggestion 106 | 107 | # Namespace preferences 108 | csharp_style_namespace_declarations = file_scoped 109 | 110 | ############################### 111 | # C# Formatting Rules # 112 | ############################### 113 | # New line preferences 114 | csharp_new_line_before_open_brace = all 115 | csharp_new_line_before_else = true 116 | csharp_new_line_before_catch = true 117 | csharp_new_line_before_finally = true 118 | csharp_new_line_before_members_in_object_initializers = true 119 | csharp_new_line_before_members_in_anonymous_types = true 120 | csharp_new_line_between_query_expression_clauses = true 121 | 122 | # Indentation preferences 123 | csharp_indent_case_contents = true 124 | csharp_indent_switch_labels = true 125 | csharp_indent_labels = flush_left 126 | 127 | # Space preferences 128 | csharp_space_after_cast = false 129 | csharp_space_after_keywords_in_control_flow_statements = true 130 | csharp_space_between_method_call_parameter_list_parentheses = false 131 | csharp_space_between_method_declaration_parameter_list_parentheses = false 132 | csharp_space_between_parentheses = false 133 | csharp_space_before_colon_in_inheritance_clause = true 134 | csharp_space_after_colon_in_inheritance_clause = true 135 | csharp_space_around_binary_operators = before_and_after 136 | csharp_space_between_method_declaration_empty_parameter_list_parentheses = false 137 | csharp_space_between_method_call_name_and_opening_parenthesis = false 138 | csharp_space_between_method_call_empty_parameter_list_parentheses = false 139 | 140 | # Wrapping preferences 141 | csharp_preserve_single_line_statements = true 142 | csharp_preserve_single_line_blocks = true 143 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sh eol=lf 2 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | To contribute changes (source code, scripts, configuration) to this repository please follow the steps below. 4 | These steps are a guideline for contributing and do not necessarily need to be followed for all changes. 5 | 6 | 1. If you intend to fix a bug please create an issue before forking the repository. 7 | 1. Fork the `main` branch of this repository from the latest commit. 8 | 1. Create a branch from your fork's `main` branch to help isolate your changes from any further work on `main`. If fixing an issue try to reference its name in your branch name (e.g. `issue-123`) to make changes easier to track the changes. 9 | 1. Work on your proposed changes on your fork. If you are fixing an issue include at least one unit test that reproduces it if the code changes to fix it have not been applied; if you are adding new functionality please include unit tests appropriate to the changes you are making. 10 | 1. When you think your changes are complete, test that the code builds cleanly using `build.ps1`. There should be no compiler warnings and all tests should pass. 11 | 1. Once your changes build cleanly locally submit a Pull Request back to the `main` branch from your fork's branch. Ideally commits to your branch should be squashed before creating the Pull Request. If the Pull Request fixes an issue please reference it in the title and/or description. Please keep changes focused around a specific topic rather than include multiple types of changes in a single Pull Request. 12 | 1. After your Pull Request is created it will build against the repository's continuous integrations. 13 | 1. Once the Pull Request has been reviewed by the project's [contributors](https://github.com/martincostello/dotnet-minimal-api-integration-testing/graphs/contributors) and the status checks pass your Pull Request will be merged back to the `main` branch, assuming that the changes are deemed appropriate. 14 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [martincostello] 2 | buy_me_a_coffee: martincostello 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: '' 3 | --- 4 | 5 | ### Expected behaviour 6 | 7 | 8 | 9 | ### Actual behaviour 10 | 11 | 12 | 13 | ### Steps to reproduce 14 | 15 | 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/10_bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug report 2 | description: Something not behaving as expected? 3 | title: '[Bug]: ' 4 | labels: ['bug'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Please check for an existing issue and the [README](https://github.com/martincostello/dotnet-minimal-api-integration-testing/blob/main/README.md) before submitting a bug report. 10 | - type: textarea 11 | attributes: 12 | label: Describe the bug 13 | description: A clear and concise description of what the bug is. 14 | validations: 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: Expected behaviour 19 | description: A clear and concise description of what you expected to happen. 20 | validations: 21 | required: false 22 | - type: textarea 23 | attributes: 24 | label: Actual behaviour 25 | description: What actually happens. 26 | validations: 27 | required: false 28 | - type: textarea 29 | attributes: 30 | label: Steps to reproduce 31 | description: | 32 | Provide a link to a [minimalistic project which reproduces this issue (repro)](https://stackoverflow.com/help/mcve) hosted in a **public** GitHub repository. 33 | Code snippets, such as a failing unit test or small console app, which demonstrate the issue wrapped in a [fenced code block](https://docs.github.com/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks) are also acceptable. 34 | 35 | This issue will be closed if: 36 | - The behaviour you're reporting cannot be easily reproduced. 37 | - The issue is a duplicate of an existing issue. 38 | - The behaviour you're reporting is by design. 39 | validations: 40 | required: false 41 | - type: textarea 42 | attributes: 43 | label: Exception(s) (if any) 44 | description: Include any exception(s) and/or stack trace(s) you get when facing this issue. 45 | render: text 46 | validations: 47 | required: false 48 | - type: textarea 49 | attributes: 50 | label: Anything else? 51 | description: | 52 | Links? References? Anything that will give us more context about the issue you are encountering is useful. 53 | 54 | 💡Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 55 | validations: 56 | required: false 57 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/20_feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 💡 Feature request 2 | description: Suggest a feature request or improvement 3 | title: '[Feature request]: ' 4 | labels: ['feature-request'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Please check for an existing issue and the [README](https://github.com/martincostello/dotnet-minimal-api-integration-testing/blob/main/README.md) before submitting a feature request. 10 | - type: textarea 11 | attributes: 12 | label: Is your feature request related to a specific problem? Or an existing feature? 13 | description: A clear and concise description of what the problem is. Motivating examples help prioritise things. 14 | placeholder: I am trying to [...] but [...] 15 | validations: 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: Describe the solution you'd like 20 | description: | 21 | A clear and concise description of what you want to happen. Include any alternative solutions you've considered. 22 | validations: 23 | required: true 24 | - type: textarea 25 | attributes: 26 | label: Additional context 27 | description: | 28 | Add any other context or screenshots about the feature request here. 29 | validations: 30 | required: false 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/30_question.yml: -------------------------------------------------------------------------------- 1 | name: 🤔 Question? 2 | description: You have something specific to achieve and the existing documentation hasn't covered how. 3 | title: '[Question]: ' 4 | labels: ['question'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Please check for an existing issue and the [README](https://github.com/martincostello/dotnet-minimal-api-integration-testing/blob/main/README.md) before asking a question. 10 | - type: textarea 11 | attributes: 12 | label: What do you want to achieve? 13 | description: A clear and concise description of what you're trying to do. 14 | placeholder: I am trying to [...] but [...] 15 | validations: 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: What code or approach do you have so far? 20 | description: | 21 | Provide a [minimalistic project which shows what you have so far](https://stackoverflow.com/help/mcve) hosted in a **public** GitHub repository. 22 | Code snippets wrapped in a [fenced code block](https://docs.github.com/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks) are also acceptable. 23 | validations: 24 | required: true 25 | - type: textarea 26 | attributes: 27 | label: Additional context 28 | description: | 29 | Add any other context or screenshots related to your question here. 30 | validations: 31 | required: false 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Contact me 4 | url: https://martincostello.com/bluesky 5 | about: You can also contact me on Bluesky. 6 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /.github/actionlint-matcher.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "actionlint", 5 | "pattern": [ 6 | { 7 | "regexp": "^(?:\\x1b\\[\\d+m)?(.+?)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*: (?:\\x1b\\[\\d+m)*(.+?)(?:\\x1b\\[\\d+m)* \\[(.+?)\\]$", 8 | "file": 1, 9 | "line": 2, 10 | "column": 3, 11 | "message": 4, 12 | "code": 5 13 | } 14 | ] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "05:30" 8 | timezone: Europe/London 9 | - package-ecosystem: nuget 10 | directory: "/" 11 | groups: 12 | xunit: 13 | patterns: 14 | - xunit* 15 | schedule: 16 | interval: daily 17 | time: "05:30" 18 | timezone: Europe/London 19 | open-pull-requests-limit: 99 20 | - package-ecosystem: npm 21 | directory: "/src/TodoApp" 22 | groups: 23 | babel: 24 | patterns: 25 | - "@babel/*" 26 | typescript-eslint: 27 | patterns: 28 | - "@typescript-eslint/*" 29 | schedule: 30 | interval: daily 31 | time: "05:30" 32 | timezone: Europe/London 33 | open-pull-requests-limit: 99 34 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths-ignore: 7 | - '**/*.md' 8 | - '**/*.gitignore' 9 | - '**/*.gitattributes' 10 | pull_request: 11 | branches: 12 | - main 13 | - dotnet-vnext 14 | - dotnet-nightly 15 | workflow_dispatch: 16 | 17 | env: 18 | DOTNET_NOLOGO: true 19 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 20 | DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: 1 21 | FORCE_COLOR: 1 22 | NUGET_XMLDOC_MODE: skip 23 | TERM: xterm 24 | 25 | permissions: {} 26 | 27 | jobs: 28 | build: 29 | name: ${{ matrix.os }} 30 | runs-on: ${{ matrix.os }} 31 | 32 | permissions: 33 | contents: read 34 | 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | include: 39 | - os: macos-latest 40 | os-name: macos 41 | - os: ubuntu-latest 42 | os-name: linux 43 | - os: windows-latest 44 | os-name: windows 45 | 46 | steps: 47 | 48 | - name: Checkout code 49 | uses: actions/checkout@v4 50 | 51 | - name: Setup .NET SDK 52 | uses: actions/setup-dotnet@v4 53 | 54 | - name: Setup Node 55 | uses: actions/setup-node@v4 56 | with: 57 | node-version: '22' 58 | 59 | - name: Build, Test and Publish 60 | shell: pwsh 61 | run: ./build.ps1 62 | env: 63 | DOTNET_CLI_TELEMETRY_OPTOUT: true 64 | DOTNET_NOLOGO: true 65 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 66 | NUGET_XMLDOC_MODE: skip 67 | 68 | - name: Upload coverage to Codecov 69 | uses: codecov/codecov-action@v5 70 | with: 71 | flags: ${{ matrix.os-name }} 72 | token: ${{ secrets.CODECOV_TOKEN }} 73 | 74 | - name: Upload test results to Codecov 75 | uses: codecov/test-results-action@v1 76 | if: ${{ !cancelled() }} 77 | with: 78 | flags: ${{ matrix.os-name }} 79 | token: ${{ secrets.CODECOV_TOKEN }} 80 | 81 | - name: Publish screenshots 82 | uses: actions/upload-artifact@v4 83 | if: ${{ !cancelled() }} 84 | with: 85 | name: screenshots-${{ matrix.os-name }} 86 | path: ./artifacts/screenshots/*.png 87 | if-no-files-found: ignore 88 | 89 | - name: Publish traces 90 | uses: actions/upload-artifact@v4 91 | if: ${{ !cancelled() }} 92 | with: 93 | name: traces-${{ matrix.os-name }} 94 | path: ./artifacts/traces/* 95 | if-no-files-found: ignore 96 | 97 | - name: Publish videos 98 | uses: actions/upload-artifact@v4 99 | if: ${{ !cancelled() }} 100 | with: 101 | name: videos-${{ matrix.os-name }} 102 | path: ./artifacts/videos/* 103 | if-no-files-found: ignore 104 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: codeql 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: 8 | - main 9 | - dotnet-vnext 10 | - dotnet-nightly 11 | schedule: 12 | - cron: '0 6 * * 1' 13 | workflow_dispatch: 14 | 15 | permissions: {} 16 | 17 | jobs: 18 | analysis: 19 | runs-on: ubuntu-latest 20 | 21 | permissions: 22 | actions: read 23 | contents: read 24 | security-events: write 25 | 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | language: [ 'actions', 'csharp', 'javascript' ] 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v4 34 | 35 | - name: Initialize CodeQL 36 | uses: github/codeql-action/init@v3 37 | with: 38 | build-mode: none 39 | languages: ${{ matrix.language }} 40 | queries: security-and-quality 41 | 42 | - name: Perform CodeQL Analysis 43 | uses: github/codeql-action/analyze@v3 44 | with: 45 | category: '/language:${{ matrix.language }}' 46 | 47 | codeql: 48 | if: ${{ !cancelled() }} 49 | needs: [ analysis ] 50 | runs-on: ubuntu-latest 51 | 52 | steps: 53 | - name: Report status 54 | shell: bash 55 | env: 56 | SCAN_SUCCESS: ${{ !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }} 57 | run: | 58 | if [ "${SCAN_SUCCESS}" == "true" ] 59 | then 60 | echo 'CodeQL analysis successful ✅' 61 | else 62 | echo 'CodeQL analysis failed ❌' 63 | exit 1 64 | fi 65 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: dependency-review 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - dotnet-vnext 8 | - dotnet-nightly 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | dependency-review: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | 22 | - name: Review dependencies 23 | uses: actions/dependency-review-action@v4 24 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths-ignore: 7 | - '**/*.gitattributes' 8 | - '**/*.gitignore' 9 | - '**/*.md' 10 | pull_request: 11 | branches: 12 | - main 13 | - dotnet-vnext 14 | - dotnet-nightly 15 | workflow_dispatch: 16 | 17 | permissions: 18 | contents: read 19 | 20 | env: 21 | FORCE_COLOR: 3 22 | TERM: xterm 23 | 24 | jobs: 25 | lint: 26 | runs-on: ubuntu-latest 27 | 28 | steps: 29 | 30 | - name: Checkout code 31 | uses: actions/checkout@v4 32 | 33 | - name: Add actionlint problem matcher 34 | run: echo "::add-matcher::.github/actionlint-matcher.json" 35 | 36 | - name: Lint workflows 37 | uses: docker://rhysd/actionlint:1.7.7 38 | with: 39 | args: -color 40 | 41 | - name: Lint markdown 42 | uses: DavidAnson/markdownlint-cli2-action@v20 43 | with: 44 | config: '.markdownlint.json' 45 | globs: | 46 | **/*.md 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .dotnet 3 | .idea 4 | .metadata 5 | .settings 6 | .vs 7 | _ReSharper* 8 | _reports 9 | _UpgradeReport_Files/ 10 | artifacts/ 11 | Backup*/ 12 | bin 13 | Bin 14 | coverage 15 | coverage.* 16 | junit.xml 17 | node_modules 18 | obj 19 | packages 20 | project.lock.json 21 | src/TodoApp/wwwroot/static/ 22 | TestResults 23 | typings 24 | UpgradeLog*.htm 25 | UpgradeLog*.XML 26 | PublishProfiles 27 | *.db 28 | *.db-shm 29 | *.db-wal 30 | *.DotSettings 31 | *.GhostDoc.xml 32 | *.log 33 | *.nupkg 34 | *.opensdf 35 | *.[Pp]ublish.xml 36 | *.publishproj 37 | *.pubxml 38 | *.sdf 39 | *.sln.cache 40 | *.sln.docstates 41 | *.sln.ide 42 | *.suo 43 | *.user 44 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD013": false, 3 | "MD040": false 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "davidanson.vscode-markdownlint", 4 | "editorconfig.editorconfig", 5 | "github.vscode-github-actions", 6 | "ms-dotnettools.csharp", 7 | "ms-vscode.PowerShell" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Launch TodoApp", 6 | "type": "coreclr", 7 | "request": "launch", 8 | "preLaunchTask": "build", 9 | "program": "${workspaceFolder}/src/TodoApp/bin/Debug/net9.0/TodoApp.dll", 10 | "args": [], 11 | "cwd": "${workspaceFolder}/src/TodoApp", 12 | "stopAtEntry": false, 13 | "serverReadyAction": { 14 | "action": "openExternally", 15 | "pattern": "\\bNow listening on:\\s+(https?://\\S+)" 16 | }, 17 | "env": { 18 | "ASPNETCORE_ENVIRONMENT": "Development", 19 | "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "${env:CODESPACES}", 20 | "GitHub__ClientId": "${env:TODOAPP_GITHUB_CLIENTID}", 21 | "GitHub__ClientSecret": "${env:TODOAPP_GITHUB_CLIENTSECRET}" 22 | } 23 | }, 24 | { 25 | "name": "Run tests", 26 | "type": "coreclr", 27 | "request": "launch", 28 | "preLaunchTask": "build", 29 | "program": "dotnet", 30 | "args": [ 31 | "test" 32 | ], 33 | "cwd": "${workspaceFolder}/tests/TodoApp.Tests", 34 | "console": "internalConsole", 35 | "stopAtEntry": false, 36 | "internalConsoleOptions": "openOnSessionStart" 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet build", 7 | "type": "shell", 8 | "group": "build", 9 | "presentation": { 10 | "reveal": "silent" 11 | }, 12 | "problemMatcher": "$msCompile" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vsconfig: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "components": [ 4 | "Microsoft.VisualStudio.Component.CoreEditor", 5 | "Microsoft.VisualStudio.Workload.CoreEditor", 6 | "Microsoft.NetCore.Component.Runtime.9.0", 7 | "Microsoft.NetCore.Component.SDK", 8 | "Microsoft.VisualStudio.Component.Roslyn.Compiler", 9 | "Microsoft.VisualStudio.Component.Roslyn.LanguageServices", 10 | "Microsoft.VisualStudio.ComponentGroup.WebToolsExtensions", 11 | "Microsoft.VisualStudio.Component.JavaScript.TypeScript", 12 | "Microsoft.VisualStudio.Component.JavaScript.Diagnostics", 13 | "Component.Microsoft.VisualStudio.RazorExtension", 14 | "Microsoft.VisualStudio.Component.Node.Tools", 15 | "Microsoft.VisualStudio.Workload.Node" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team [through a GitHub issue](https://github.com/martincostello/dotnet-minimal-api-integration-testing/issues). 59 | All complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | AllEnabledByDefault 4 | martin_costello 5 | $(MSBuildThisFileDirectory)TodoApp.ruleset 6 | https://github.com/martincostello/dotnet-minimal-api-integration-testing 7 | Martin Costello (c) $([System.DateTime]::Now.ToString(yyyy)) 8 | true 9 | false 10 | enable 11 | latest 12 | true 13 | en-US 14 | enable 15 | Apache-2.0 16 | https://github.com/martincostello/dotnet-minimal-api-integration-testing 17 | See $(PackageProjectUrl)/releases for details. 18 | false 19 | git 20 | $(PackageProjectUrl).git 21 | true 22 | 1.0.0 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /NuGet.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Integration Testing ASP.NET Core Minimal APIs 2 | 3 | [![Build status](https://github.com/martincostello/dotnet-minimal-api-integration-testing/actions/workflows/build.yml/badge.svg?branch=main&event=push)](https://github.com/martincostello/dotnet-minimal-api-integration-testing/actions/workflows/build.yml?query=branch%3Amain+event%3Apush) 4 | [![codecov](https://codecov.io/gh/martincostello/dotnet-minimal-api-integration-testing/branch/main/graph/badge.svg)](https://codecov.io/gh/martincostello/dotnet-minimal-api-integration-testing) 5 | 6 | ## Introduction 7 | 8 | This sample project demonstrates techniques you can use for integration testing 9 | an ASP.NET Core web application that uses the [minimal APIs] feature. 10 | 11 | [minimal APIs]: https://devblogs.microsoft.com/aspnet/asp-net-core-updates-in-net-6-preview-4/#introducing-minimal-apis 12 | 13 | The system-under-test used by the sample implements a simple Todo list 14 | application with ASP.NET Core using the following technologies: 15 | 16 | * [Minimal APIs] 17 | * [EFCore] with [SQLite] 18 | * [GitHub OAuth] authentication 19 | * [Razor Pages] 20 | * [TypeScript] 21 | 22 | [EFCore]: https://docs.microsoft.com/en-us/ef/core/ 23 | [GitHub OAuth]: https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers/blob/dev/docs/github.md 24 | [Razor Pages]: https://docs.microsoft.com/en-us/aspnet/core/razor-pages/ 25 | [SQLite]: https://docs.microsoft.com/en-us/ef/core/providers/sqlite/ 26 | [TypeScript]: https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes-oop.html 27 | 28 | The tests show how you can write integration tests for the [API] and [User 29 | Interface] layers of an application that can help you get good coverage of the 30 | system-under-test, as well as help give you confidence that the changes you make 31 | to an application are ready to ship to a production system. 32 | 33 | The tests include demonstrations of the use of the following open source 34 | libraries and technologies: 35 | 36 | * [coverlet] 37 | * [HttpClientFactory] 38 | * [HttpClient Interception] 39 | * [Playwright] 40 | * [ReportGenerator] 41 | * [Shouldly] 42 | * [WebApplicationFactory<T>] 43 | * [xunit] 44 | * [xunit Logging] 45 | 46 | [API]: https://github.com/martincostello/dotnet-minimal-api-integration-testing/blob/main/tests/TodoApp.Tests/ApiTests.cs 47 | [coverlet]: https://github.com/coverlet-coverage/coverlet 48 | [HttpClientFactory]: https://docs.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests 49 | [HttpClient Interception]: https://github.com/justeat/httpclient-interception 50 | [Playwright]: https://playwright.dev/dotnet/ 51 | [ReportGenerator]: https://github.com/danielpalme/ReportGenerator 52 | [Shouldly]: https://docs.shouldly.org 53 | [User Interface]: https://github.com/martincostello/dotnet-minimal-api-integration-testing/blob/main/tests/TodoApp.Tests/UITests.cs 54 | [WebApplicationFactory<T>]: https://docs.microsoft.com/en-us/aspnet/core/test/integration-tests 55 | [xunit]: https://xunit.net/ 56 | [xunit Logging]: https://github.com/martincostello/xunit-logging 57 | 58 | ## Debugging 59 | 60 | To debug the application locally outside of the integration tests, you will need 61 | to [create a GitHub OAuth app] to obtain secrets for the `GitHub:ClientId` and 62 | `GitHub:ClientSecret` [options] so that the [OAuth user authentication] works and 63 | you can log into the Todo App UI. 64 | 65 | > 💡 When creating the GitHub OAuth app, use `https://localhost:50001/sign-in-github` 66 | as the _Authorization callback URL_. 67 | > 68 | > ⚠️ Do not commit GitHub OAuth secrets to source control. Configure them 69 | with [User Secrets] instead. 70 | 71 | [create a GitHub OAuth app]: https://docs.github.com/en/developers/apps/building-oauth-apps/creating-an-oauth-app 72 | [OAuth user authentication]: https://docs.microsoft.com/en-us/aspnet/core/security/authentication/social/?view=aspnetcore-5.0&tabs=visual-studio 73 | [options]: https://github.com/martincostello/dotnet-minimal-api-integration-testing/blob/1cd99029a9e3af57ab2fe1335b43e298efb65c09/src/TodoApp/appsettings.json#L10-L11 74 | [User Secrets]: https://docs.microsoft.com/en-us/aspnet/core/security/app-secrets 75 | 76 | ## Building and Testing 77 | 78 | Compiling the application yourself requires Git and the 79 | [.NET SDK](https://www.microsoft.com/net/download/core "Download the .NET SDK") 80 | to be installed (version `9.0.100` or later). 81 | 82 | To build and test the application locally from a terminal/command-line, run the 83 | following set of commands: 84 | 85 | ```powershell 86 | git clone https://github.com/martincostello/dotnet-minimal-api-integration-testing.git 87 | cd dotnet-minimal-api-integration-testing 88 | ./build.ps1 89 | ``` 90 | 91 | ## Feedback 92 | 93 | Any feedback or issues can be added to the issues for this project in 94 | [GitHub](https://github.com/martincostello/dotnet-minimal-api-integration-testing/issues "Issues for this project on GitHub.com"). 95 | 96 | ## Acknowledgements 97 | 98 | Thanks to David Fowler ([@davidfowl](https://github.com/davidfowl)) from the 99 | ASP.NET Core team for helping out with resolving issues with Minimal Actions 100 | found from testing this sample with the ASP.NET Core 6 pre-releases! 101 | 102 | ## Repository 103 | 104 | The repository is hosted in 105 | [GitHub](https://github.com/martincostello/dotnet-minimal-api-integration-testing "This project on GitHub.com"): 106 | 107 | 108 | ## License 109 | 110 | This project is licensed under the 111 | [Apache 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt "The Apache 2.0 license") 112 | license. 113 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | To privately report a security vulnerability, please create a security advisory in the [repository's Security tab](https://github.com/martincostello/dotnet-minimal-api-integration-testing/security/advisories). 6 | 7 | Further details can be found in the [GitHub documentation](https://docs.github.com/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability). 8 | -------------------------------------------------------------------------------- /TodoApp.ruleset: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /TodoApp.slnx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /build.ps1: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env pwsh 2 | 3 | #Requires -PSEdition Core 4 | #Requires -Version 7 5 | 6 | param( 7 | [Parameter(Mandatory = $false)][string] $OutputPath = "", 8 | [Parameter(Mandatory = $false)][switch] $SkipTests 9 | ) 10 | 11 | $Configuration = "Release" 12 | $ErrorActionPreference = "Stop" 13 | $ProgressPreference = "SilentlyContinue" 14 | 15 | $solutionPath = $PSScriptRoot 16 | $sdkFile = Join-Path $solutionPath "global.json" 17 | 18 | $dotnetVersion = (Get-Content $sdkFile | Out-String | ConvertFrom-Json).sdk.version 19 | 20 | if ([string]::IsNullOrEmpty($OutputPath)) { 21 | $OutputPath = Join-Path $solutionPath "artifacts" 22 | } 23 | 24 | $installDotNetSdk = $false; 25 | 26 | if (($null -eq (Get-Command "dotnet" -ErrorAction SilentlyContinue)) -and ($null -eq (Get-Command "dotnet.exe" -ErrorAction SilentlyContinue))) { 27 | Write-Output "The .NET SDK is not installed." 28 | $installDotNetSdk = $true 29 | } 30 | else { 31 | Try { 32 | $installedDotNetVersion = (dotnet --version 2>&1 | Out-String).Trim() 33 | } 34 | Catch { 35 | $installedDotNetVersion = "?" 36 | } 37 | 38 | if ($installedDotNetVersion -ne $dotnetVersion) { 39 | Write-Output "The required version of the .NET SDK is not installed. Expected $dotnetVersion." 40 | $installDotNetSdk = $true 41 | } 42 | } 43 | 44 | if ($installDotNetSdk -eq $true) { 45 | 46 | ${env:DOTNET_INSTALL_DIR} = Join-Path $solutionPath ".dotnet" 47 | $sdkPath = Join-Path ${env:DOTNET_INSTALL_DIR} "sdk" $dotnetVersion 48 | 49 | if (!(Test-Path $sdkPath)) { 50 | if (-Not (Test-Path ${env:DOTNET_INSTALL_DIR})) { 51 | mkdir ${env:DOTNET_INSTALL_DIR} | Out-Null 52 | } 53 | [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor "Tls12" 54 | if (($PSVersionTable.PSVersion.Major -ge 6) -And !$IsWindows) { 55 | $installScript = Join-Path ${env:DOTNET_INSTALL_DIR} "install.sh" 56 | Invoke-WebRequest "https://dot.net/v1/dotnet-install.sh" -OutFile $installScript -UseBasicParsing 57 | chmod +x $installScript 58 | & $installScript --jsonfile $sdkFile --install-dir ${env:DOTNET_INSTALL_DIR} --no-path --skip-non-versioned-files 59 | } 60 | else { 61 | $installScript = Join-Path ${env:DOTNET_INSTALL_DIR} "install.ps1" 62 | Invoke-WebRequest "https://dot.net/v1/dotnet-install.ps1" -OutFile $installScript -UseBasicParsing 63 | & $installScript -JsonFile $sdkFile -InstallDir ${env:DOTNET_INSTALL_DIR} -NoPath -SkipNonVersionedFiles 64 | } 65 | } 66 | } 67 | else { 68 | ${env:DOTNET_INSTALL_DIR} = Split-Path -Path (Get-Command dotnet).Path 69 | } 70 | 71 | $dotnet = Join-Path ${env:DOTNET_INSTALL_DIR} "dotnet" 72 | 73 | if ($installDotNetSdk -eq $true) { 74 | ${env:PATH} = "${env:DOTNET_INSTALL_DIR};${env:PATH}" 75 | } 76 | 77 | function DotNetTest { 78 | param([string]$Project) 79 | 80 | $additionalArgs = @() 81 | 82 | if (-Not [string]::IsNullOrEmpty(${env:GITHUB_SHA})) { 83 | $additionalArgs += "--logger:GitHubActions;report-warnings=false" 84 | $additionalArgs += "--logger:junit;LogFilePath=junit.xml" 85 | } 86 | 87 | & $dotnet test $Project --output $OutputPath --configuration $Configuration $additionalArgs 88 | 89 | if ($LASTEXITCODE -ne 0) { 90 | throw "dotnet test failed with exit code $LASTEXITCODE" 91 | } 92 | } 93 | 94 | function DotNetPublish { 95 | param([string]$Project) 96 | 97 | $publishPath = Join-Path $OutputPath "publish" 98 | & $dotnet publish $Project --output $publishPath 99 | 100 | if ($LASTEXITCODE -ne 0) { 101 | throw "dotnet publish failed with exit code $LASTEXITCODE" 102 | } 103 | } 104 | 105 | $publishProjects = @( 106 | (Join-Path $solutionPath "src" "TodoApp" "TodoApp.csproj") 107 | ) 108 | 109 | $testProjects = @( 110 | (Join-Path $solutionPath "tests" "TodoApp.Tests" "TodoApp.Tests.csproj") 111 | ) 112 | 113 | Write-Output "Publishing solution..." 114 | ForEach ($project in $publishProjects) { 115 | DotNetPublish $project $Configuration 116 | } 117 | 118 | if (-Not $SkipTests) { 119 | Write-Host "Testing $($testProjects.Count) project(s)..." -ForegroundColor Green 120 | ForEach ($project in $testProjects) { 121 | DotNetTest $project 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "9.0.300", 4 | "allowPrerelease": false, 5 | "rollForward": "latestMajor", 6 | "paths": [ ".dotnet", "$host$" ], 7 | "errorMessage": "The required version of the .NET SDK could not be found. Please run ./build.ps1 to bootstrap the .NET SDK." 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /omnisharp.json: -------------------------------------------------------------------------------- 1 | { 2 | "fileOptions": { 3 | "systemExcludeSearchPatterns": [ 4 | "**/bin/**/*", 5 | "**/obj/**/*", 6 | "**/node_modules/**/*" 7 | ], 8 | "excludeSearchPatterns": [] 9 | }, 10 | "FormattingOptions": { 11 | "enableEditorConfigSupport": true 12 | }, 13 | "msbuild": { 14 | "MSBuildSDKsPath": ".\\.dotnet", 15 | "EnablePackageAutoRestore": true, 16 | "loadProjectsOnDemand": true 17 | }, 18 | "RoslynExtensionsOptions": { 19 | "enableAnalyzersSupport": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/TodoApp/.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | -------------------------------------------------------------------------------- /src/TodoApp/.prettierignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | node_modules/ 3 | obj/ 4 | wwwroot/ 5 | -------------------------------------------------------------------------------- /src/TodoApp/ApiEndpoints.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.Http.HttpResults; 5 | using Microsoft.EntityFrameworkCore; 6 | using TodoApp.Data; 7 | using TodoApp.Models; 8 | using TodoApp.Services; 9 | 10 | namespace TodoApp; 11 | 12 | /// 13 | /// A class containing the HTTP endpoints for the Todo API. 14 | /// 15 | public static class ApiEndpoints 16 | { 17 | /// 18 | /// Adds the services for the Todo API to the application. 19 | /// 20 | /// The . 21 | /// 22 | /// A that can be used to further configure the application. 23 | /// 24 | public static IServiceCollection AddTodoApi(this IServiceCollection services) 25 | { 26 | services.AddSingleton(TimeProvider.System); 27 | services.AddScoped(); 28 | services.AddScoped(); 29 | 30 | services.AddDbContext((serviceProvider, options) => 31 | { 32 | var configuration = serviceProvider.GetRequiredService(); 33 | var dataDirectory = configuration["DataDirectory"]; 34 | 35 | if (string.IsNullOrEmpty(dataDirectory) || !Path.IsPathRooted(dataDirectory)) 36 | { 37 | var environment = serviceProvider.GetRequiredService(); 38 | dataDirectory = Path.Combine(environment.ContentRootPath, "App_Data"); 39 | } 40 | 41 | // Ensure the configured data directory exists 42 | if (!Directory.Exists(dataDirectory)) 43 | { 44 | Directory.CreateDirectory(dataDirectory); 45 | } 46 | 47 | var databaseFile = Path.Combine(dataDirectory, "TodoApp.db"); 48 | 49 | options.UseSqlite("Data Source=" + databaseFile); 50 | }); 51 | 52 | return services; 53 | } 54 | 55 | /// 56 | /// Maps the endpoints for the Todo API. 57 | /// 58 | /// The . 59 | /// 60 | /// A that can be used to further customize the endpoint. 61 | /// 62 | public static IEndpointRouteBuilder MapTodoApiRoutes(this IEndpointRouteBuilder builder) 63 | { 64 | var group = builder.MapGroup("/api/items") 65 | .RequireAuthorization(); 66 | { 67 | group.MapGet("/", async ( 68 | [AsParameters] TodoRequestContext context, 69 | CancellationToken cancellationToken) => 70 | { 71 | return await context.Service.GetListAsync(context.User, cancellationToken); 72 | }) 73 | .WithSummary("Get all Todo items") 74 | .WithDescription("Gets all of the current user's todo items."); 75 | 76 | group.MapGet("/{id}", async Task, ProblemHttpResult>> ( 77 | Guid id, 78 | [AsParameters] TodoRequestContext context, 79 | CancellationToken cancellationToken) => 80 | { 81 | var model = await context.Service.GetAsync(context.User, id, cancellationToken); 82 | return model switch 83 | { 84 | null => TypedResults.Problem("Item not found.", statusCode: StatusCodes.Status404NotFound), 85 | _ => TypedResults.Ok(model), 86 | }; 87 | }) 88 | .ProducesProblem(StatusCodes.Status404NotFound) 89 | .WithSummary("Get a specific Todo item") 90 | .WithDescription("Gets the todo item with the specified ID."); 91 | 92 | group.MapPost("/", async Task, ProblemHttpResult>> ( 93 | CreateTodoItemModel model, 94 | [AsParameters] TodoRequestContext context, 95 | CancellationToken cancellationToken) => 96 | { 97 | if (string.IsNullOrWhiteSpace(model.Text)) 98 | { 99 | return TypedResults.Problem("No item text specified.", statusCode: StatusCodes.Status400BadRequest); 100 | } 101 | 102 | var id = await context.Service.AddItemAsync(context.User, model.Text, cancellationToken); 103 | 104 | return TypedResults.Created($"/api/items/{id}", new CreatedTodoItemModel() { Id = id }); 105 | }) 106 | .ProducesProblem(StatusCodes.Status400BadRequest) 107 | .WithSummary("Create a new Todo item") 108 | .WithDescription("Creates a new todo item for the current user and returns its ID."); 109 | 110 | group.MapPost("/{id}/complete", async Task> ( 111 | Guid id, 112 | [AsParameters] TodoRequestContext context, 113 | CancellationToken cancellationToken) => 114 | { 115 | var wasCompleted = await context.Service.CompleteItemAsync(context.User, id, cancellationToken); 116 | 117 | return wasCompleted switch 118 | { 119 | true => TypedResults.NoContent(), 120 | false => TypedResults.Problem("Item already completed.", statusCode: StatusCodes.Status400BadRequest), 121 | _ => TypedResults.Problem("Item not found.", statusCode: StatusCodes.Status404NotFound), 122 | }; 123 | }) 124 | .ProducesProblem(StatusCodes.Status400BadRequest) 125 | .ProducesProblem(StatusCodes.Status404NotFound) 126 | .WithSummary("Mark a Todo item as completed") 127 | .WithDescription("Marks the todo item with the specified ID as complete."); 128 | 129 | group.MapDelete("/{id}", async Task> ( 130 | Guid id, 131 | [AsParameters] TodoRequestContext context, 132 | CancellationToken cancellationToken) => 133 | { 134 | var wasDeleted = await context.Service.DeleteItemAsync(context.User, id, cancellationToken); 135 | return wasDeleted switch 136 | { 137 | true => TypedResults.NoContent(), 138 | false => TypedResults.Problem("Item not found.", statusCode: StatusCodes.Status404NotFound), 139 | }; 140 | }) 141 | .ProducesProblem(StatusCodes.Status404NotFound) 142 | .WithSummary("Delete a Todo item") 143 | .WithDescription("Deletes the todo item with the specified ID."); 144 | }; 145 | 146 | // Redirect to Open API/Swagger documentation 147 | builder.MapGet("/api", () => Results.Redirect("/swagger-ui/index.html")) 148 | .ExcludeFromDescription() 149 | .RequireAuthorization(); 150 | 151 | return builder; 152 | } 153 | 154 | private record struct TodoRequestContext(TodoUser User, ITodoService Service); 155 | 156 | private readonly struct TodoUser 157 | { 158 | private TodoUser(string id) 159 | { 160 | Id = id; 161 | } 162 | 163 | public string Id { get; } 164 | 165 | public static implicit operator string(TodoUser value) => value.Id; 166 | 167 | public static ValueTask BindAsync(HttpContext context) 168 | { 169 | return ValueTask.FromResult(new TodoUser(context.User.GetUserId())); 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/TodoApp/AuthenticationEndpoints.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Security.Claims; 5 | using AspNet.Security.OAuth.GitHub; 6 | using Microsoft.AspNetCore.Authentication; 7 | using Microsoft.AspNetCore.Authentication.Cookies; 8 | 9 | namespace TodoApp; 10 | 11 | /// 12 | /// A class containing the HTTP endpoints and extension methods for authentication. 13 | /// 14 | public static class AuthenticationEndpoints 15 | { 16 | private const string DeniedPath = "/denied"; 17 | private const string RootPath = "/"; 18 | private const string SignInPath = "/sign-in"; 19 | private const string SignOutPath = "/sign-out"; 20 | 21 | private const string GitHubAvatarClaim = "urn:github:avatar"; 22 | private const string GitHubProfileClaim = "urn:github:profile"; 23 | 24 | /// 25 | /// Adds GitHub authentication to the application. 26 | /// 27 | /// The . 28 | /// 29 | /// A that can be used to further configure the application. 30 | /// 31 | public static IServiceCollection AddGitHubAuthentication(this IServiceCollection services) 32 | { 33 | return services 34 | .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) 35 | .AddCookie(options => 36 | { 37 | options.LoginPath = SignInPath; 38 | options.LogoutPath = SignOutPath; 39 | }) 40 | .AddGitHub() 41 | .Services 42 | .AddOptions(GitHubAuthenticationDefaults.AuthenticationScheme) 43 | .Configure((options, configuration) => 44 | { 45 | options.AccessDeniedPath = DeniedPath; 46 | options.CallbackPath = SignInPath + "-github"; 47 | options.ClientId = configuration["GitHub:ClientId"] ?? string.Empty; 48 | options.ClientSecret = configuration["GitHub:ClientSecret"] ?? string.Empty; 49 | options.EnterpriseDomain = configuration["GitHub:EnterpriseDomain"]; 50 | 51 | options.Scope.Add("user:email"); 52 | options.ClaimActions.MapJsonKey(GitHubProfileClaim, "html_url"); 53 | 54 | if (string.IsNullOrEmpty(options.EnterpriseDomain)) 55 | { 56 | options.ClaimActions.MapJsonKey(GitHubAvatarClaim, "avatar_url"); 57 | } 58 | }) 59 | .ValidateOnStart() 60 | .Services; 61 | } 62 | 63 | /// 64 | /// Gets the user's GitHub avatar URL. 65 | /// 66 | /// The current user. 67 | /// 68 | /// The GitHub avatar URL for the current user, if any. 69 | /// 70 | public static string? GetAvatarUrl(this ClaimsPrincipal user) 71 | => user.FindFirst(GitHubAvatarClaim)?.Value; 72 | 73 | /// 74 | /// Gets the user's GitHub profile URL. 75 | /// 76 | /// The current user. 77 | /// 78 | /// The GitHub profile URL for the current user. 79 | /// 80 | public static string GetProfileUrl(this ClaimsPrincipal user) 81 | => user.FindFirst(GitHubProfileClaim)!.Value; 82 | 83 | /// 84 | /// Gets the user's user Id. 85 | /// 86 | /// The current user. 87 | /// 88 | /// The user Id for the current user. 89 | /// 90 | public static string GetUserId(this ClaimsPrincipal user) 91 | => user.FindFirst(ClaimTypes.NameIdentifier)!.Value; 92 | 93 | /// 94 | /// Gets the user's user name. 95 | /// 96 | /// The current user. 97 | /// 98 | /// The user name for the current user. 99 | /// 100 | public static string GetUserName(this ClaimsPrincipal user) 101 | => user.FindFirst(GitHubAuthenticationConstants.Claims.Name)!.Value; 102 | 103 | /// 104 | /// Maps the endpoints for authentication. 105 | /// 106 | /// The . 107 | /// 108 | /// A that can be used to further customize the endpoint. 109 | /// 110 | public static IEndpointRouteBuilder MapAuthenticationRoutes(this IEndpointRouteBuilder builder) 111 | { 112 | builder.MapGet(DeniedPath, () => Results.Redirect(RootPath + "?denied=true")) 113 | .ExcludeFromDescription(); 114 | 115 | builder.MapGet(SignOutPath, () => Results.Redirect(RootPath)) 116 | .ExcludeFromDescription(); 117 | 118 | builder.MapPost(SignInPath, () => Results.Challenge(new() { RedirectUri = RootPath }, new[] { GitHubAuthenticationDefaults.AuthenticationScheme })) 119 | .ExcludeFromDescription(); 120 | 121 | builder.MapPost(SignOutPath, () => Results.SignOut(new() { RedirectUri = RootPath }, new[] { CookieAuthenticationDefaults.AuthenticationScheme })) 122 | .ExcludeFromDescription() 123 | .RequireAuthorization(); 124 | 125 | return builder; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/TodoApp/Data/ITodoRepository.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace TodoApp.Data; 5 | 6 | public interface ITodoRepository 7 | { 8 | Task AddItemAsync(string userId, string text, CancellationToken cancellationToken = default); 9 | 10 | Task CompleteItemAsync(string userId, Guid itemId, CancellationToken cancellationToken = default); 11 | 12 | Task DeleteItemAsync(string userId, Guid itemId, CancellationToken cancellationToken = default); 13 | 14 | Task GetItemAsync(string userId, Guid itemId, CancellationToken cancellationToken = default); 15 | 16 | Task> GetItemsAsync(string userId, CancellationToken cancellationToken = default); 17 | } 18 | -------------------------------------------------------------------------------- /src/TodoApp/Data/TodoContext.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.EntityFrameworkCore; 5 | 6 | namespace TodoApp.Data; 7 | 8 | public class TodoContext(DbContextOptions options) : DbContext(options) 9 | { 10 | public DbSet Items { get; set; } = default!; 11 | } 12 | -------------------------------------------------------------------------------- /src/TodoApp/Data/TodoItem.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace TodoApp.Data; 5 | 6 | public class TodoItem 7 | { 8 | public Guid Id { get; set; } 9 | 10 | public string UserId { get; set; } = default!; 11 | 12 | public string Text { get; set; } = default!; 13 | 14 | public DateTime CreatedAt { get; set; } 15 | 16 | public DateTime? CompletedAt { get; set; } 17 | } 18 | -------------------------------------------------------------------------------- /src/TodoApp/Data/TodoRepository.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.EntityFrameworkCore; 5 | 6 | namespace TodoApp.Data; 7 | 8 | public sealed class TodoRepository(TimeProvider timeProvider, TodoContext context) : ITodoRepository 9 | { 10 | public async Task AddItemAsync( 11 | string userId, 12 | string text, 13 | CancellationToken cancellationToken = default) 14 | { 15 | await EnsureDatabaseAsync(cancellationToken); 16 | 17 | var item = new TodoItem 18 | { 19 | CreatedAt = UtcNow(), 20 | Text = text, 21 | UserId = userId 22 | }; 23 | 24 | context.Add(item); 25 | 26 | await context.SaveChangesAsync(cancellationToken); 27 | 28 | return item; 29 | } 30 | 31 | public async Task CompleteItemAsync( 32 | string userId, 33 | Guid itemId, 34 | CancellationToken cancellationToken = default) 35 | { 36 | var item = await GetItemAsync(userId, itemId, cancellationToken); 37 | 38 | if (item is null) 39 | { 40 | return null; 41 | } 42 | 43 | if (item.CompletedAt.HasValue) 44 | { 45 | return false; 46 | } 47 | 48 | item.CompletedAt = UtcNow(); 49 | 50 | context.Items.Update(item); 51 | 52 | await context.SaveChangesAsync(cancellationToken); 53 | 54 | return true; 55 | } 56 | 57 | public async Task DeleteItemAsync( 58 | string userId, 59 | Guid itemId, 60 | CancellationToken cancellationToken = default) 61 | { 62 | var item = await GetItemAsync(userId, itemId, cancellationToken); 63 | 64 | if (item is null) 65 | { 66 | return false; 67 | } 68 | 69 | context.Items.Remove(item); 70 | 71 | await context.SaveChangesAsync(cancellationToken); 72 | 73 | return true; 74 | } 75 | 76 | public async Task GetItemAsync( 77 | string userId, 78 | Guid itemId, 79 | CancellationToken cancellationToken = default) 80 | { 81 | await EnsureDatabaseAsync(cancellationToken); 82 | 83 | var item = await context.Items.FindItemAsync(itemId, cancellationToken); 84 | 85 | if (item is null || !string.Equals(item.UserId, userId, StringComparison.Ordinal)) 86 | { 87 | return null; 88 | } 89 | 90 | return item; 91 | } 92 | 93 | public async Task> GetItemsAsync( 94 | string userId, 95 | CancellationToken cancellationToken = default) 96 | { 97 | await EnsureDatabaseAsync(cancellationToken); 98 | 99 | return await context.Items 100 | .Where(x => x.UserId == userId) 101 | .OrderBy(x => x.CompletedAt.HasValue) 102 | .ThenBy(x => x.CreatedAt) 103 | .ToListAsync(cancellationToken); 104 | } 105 | 106 | private async Task EnsureDatabaseAsync(CancellationToken cancellationToken) 107 | => await context.Database.EnsureCreatedAsync(cancellationToken); 108 | 109 | private DateTime UtcNow() => timeProvider.GetUtcNow().UtcDateTime; 110 | } 111 | -------------------------------------------------------------------------------- /src/TodoApp/Extensions/DbSetExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Microsoft.EntityFrameworkCore; 5 | 6 | public static class DbSetExtensions 7 | { 8 | public static ValueTask FindItemAsync( 9 | this DbSet set, 10 | TKey keyValue, 11 | CancellationToken cancellationToken) 12 | where TEntity : class 13 | { 14 | ArgumentNullException.ThrowIfNull(keyValue); 15 | return set.FindAsync(new object[] { keyValue }, cancellationToken); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/TodoApp/Models/CreateTodoItemModel.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace TodoApp.Models; 5 | 6 | public class CreateTodoItemModel 7 | { 8 | public string Text { get; set; } = string.Empty; 9 | } 10 | -------------------------------------------------------------------------------- /src/TodoApp/Models/CreatedTodoItemModel.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace TodoApp.Models; 5 | 6 | public class CreatedTodoItemModel 7 | { 8 | public string Id { get; set; } = string.Empty; 9 | } 10 | -------------------------------------------------------------------------------- /src/TodoApp/Models/TodoItemModel.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace TodoApp.Models; 5 | 6 | public class TodoItemModel 7 | { 8 | public string Id { get; set; } = default!; 9 | 10 | public string Text { get; set; } = default!; 11 | 12 | public bool IsCompleted { get; set; } 13 | 14 | public string LastUpdated { get; set; } = default!; 15 | } 16 | -------------------------------------------------------------------------------- /src/TodoApp/Models/TodoListViewModel.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace TodoApp.Models; 5 | 6 | public class TodoListViewModel 7 | { 8 | public ICollection Items { get; set; } = new List(); 9 | } 10 | -------------------------------------------------------------------------------- /src/TodoApp/Pages/Home/Index.cshtml: -------------------------------------------------------------------------------- 1 | @page "/" 2 | @model IndexModel 3 | 4 | @{ 5 | ViewData["Title"] = "My List"; 6 | } 7 | 8 |

Things To Do

9 |
10 | @if (!User.Identity!.IsAuthenticated) 11 | { 12 |

13 | Sign in with your GitHub account to manage your Todo list. 14 |

15 |
16 | 17 |
18 | } 19 | else 20 | { 21 |

22 | My List 23 |
24 | Loading... 25 |
26 |

27 | 28 | 31 | 32 |
33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 46 | 47 | 50 | 53 | 54 | 55 |
ItemLast Updated
45 | 48 | 49 | 51 | 52 |
56 |
57 | 58 |
59 | 60 |

Add New Item

61 | 62 |
63 |
64 | 65 | 72 |
73 |
74 | 75 |
76 |
77 | } 78 | -------------------------------------------------------------------------------- /src/TodoApp/Pages/Home/Index.cshtml.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.Mvc.RazorPages; 5 | 6 | namespace TodoApp.Pages; 7 | 8 | public class IndexModel : PageModel 9 | { 10 | public void OnGet() 11 | { 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/TodoApp/Pages/Shared/Error.cshtml: -------------------------------------------------------------------------------- 1 | @page "/error" 2 | @model ErrorModel 3 | 4 | @{ 5 | ViewData["Title"] = "Error"; 6 | } 7 | 8 |

Error.

9 |

An error occurred while processing your request.

10 | 11 | @if (Model.ShowRequestId) 12 | { 13 |

14 | Request ID: @Model.RequestId 15 |

16 | } 17 | -------------------------------------------------------------------------------- /src/TodoApp/Pages/Shared/Error.cshtml.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Diagnostics; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.AspNetCore.Mvc.RazorPages; 7 | 8 | namespace TodoApp.Pages; 9 | 10 | [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] 11 | public class ErrorModel : PageModel 12 | { 13 | public string RequestId { get; set; } = default!; 14 | 15 | public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); 16 | 17 | public void OnGet() 18 | { 19 | RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/TodoApp/Pages/Shared/_Layout.cshtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | @ViewData["Title"] - TodoApp 7 | 8 | 9 | 10 | 11 | 12 | 13 | 55 |
56 | @RenderBody() 57 |
58 |
59 |

© @(DateTimeOffset.UtcNow.Year) - Martin Costello

60 |
61 |
62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/TodoApp/Pages/_ViewImports.cshtml: -------------------------------------------------------------------------------- 1 | @using TodoApp; 2 | @using TodoApp.Pages; 3 | @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers 4 | -------------------------------------------------------------------------------- /src/TodoApp/Pages/_ViewStart.cshtml: -------------------------------------------------------------------------------- 1 | @{ 2 | Layout = "_Layout"; 3 | } 4 | -------------------------------------------------------------------------------- /src/TodoApp/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.HttpOverrides; 5 | using TodoApp; 6 | 7 | // Create the default web application builder 8 | var builder = WebApplication.CreateBuilder(args); 9 | 10 | // Configure the Todo repository and associated services 11 | builder.Services.AddTodoApi(); 12 | 13 | // Add user authentication with GitHub as an external OAuth provider 14 | builder.Services.AddGitHubAuthentication(); 15 | 16 | // Add Razor Pages to render the UI 17 | builder.Services.AddRazorPages(); 18 | 19 | // Configure OpenAPI documentation for the Todo API 20 | builder.Services.AddOpenApi(options => 21 | { 22 | options.AddDocumentTransformer((document, _, _) => 23 | { 24 | document.Info.Title = "Todo API"; 25 | document.Info.Version = "v1"; 26 | return Task.CompletedTask; 27 | }); 28 | }); 29 | 30 | if (string.Equals(builder.Configuration["CODESPACES"], "true", StringComparison.OrdinalIgnoreCase)) 31 | { 32 | // When running in GitHub Codespaces, X-Forwarded-Host also needs to be set 33 | builder.Services.Configure( 34 | options => options.ForwardedHeaders |= ForwardedHeaders.XForwardedHost); 35 | } 36 | 37 | // Create the app 38 | var app = builder.Build(); 39 | 40 | // Configure error handling 41 | if (!app.Environment.IsDevelopment()) 42 | { 43 | app.UseExceptionHandler("/error"); 44 | } 45 | 46 | app.UseStatusCodePagesWithReExecute("/error", "?id={0}"); 47 | 48 | // Require use of HTTPS in production 49 | if (!app.Environment.IsDevelopment()) 50 | { 51 | app.UseHsts(); 52 | app.UseHttpsRedirection(); 53 | } 54 | 55 | // Add static files for JavaScript, CSS and OpenAPI 56 | app.UseStaticFiles(); 57 | 58 | // Add authN for GitHub 59 | app.UseAuthentication(); 60 | app.UseAuthorization(); 61 | 62 | // Add endpoint for OpenAPI 63 | app.MapOpenApi(); 64 | 65 | // Add the HTTP endpoints 66 | app.MapAuthenticationRoutes(); 67 | app.MapTodoApiRoutes(); 68 | 69 | // Add Razor Pages for the UI 70 | app.MapRazorPages(); 71 | 72 | // Run the application 73 | app.Run(); 74 | 75 | public partial class Program 76 | { 77 | // Expose the Program class for use with WebApplicationFactory 78 | } 79 | -------------------------------------------------------------------------------- /src/TodoApp/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "TodoApp": { 4 | "commandName": "Project", 5 | "launchBrowser": true, 6 | "applicationUrl": "https://localhost:50001;http://localhost:50000", 7 | "environmentVariables": { 8 | "ASPNETCORE_ENVIRONMENT": "Development" 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/TodoApp/Services/ITodoService.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using TodoApp.Models; 5 | 6 | namespace TodoApp.Services; 7 | 8 | public interface ITodoService 9 | { 10 | Task AddItemAsync(string userId, string text, CancellationToken cancellationToken); 11 | 12 | Task CompleteItemAsync(string userId, Guid itemId, CancellationToken cancellationToken); 13 | 14 | Task DeleteItemAsync(string userId, Guid itemId, CancellationToken cancellationToken); 15 | 16 | Task GetAsync(string userId, Guid itemId, CancellationToken cancellationToken); 17 | 18 | Task GetListAsync(string userId, CancellationToken cancellationToken); 19 | } 20 | -------------------------------------------------------------------------------- /src/TodoApp/Services/TodoService.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using TodoApp.Data; 5 | using TodoApp.Models; 6 | 7 | namespace TodoApp.Services; 8 | 9 | public sealed class TodoService(ITodoRepository repository) : ITodoService 10 | { 11 | public async Task AddItemAsync( 12 | string userId, 13 | string text, 14 | CancellationToken cancellationToken) 15 | { 16 | var item = await repository.AddItemAsync(userId, text, cancellationToken); 17 | 18 | return item.Id.ToString(); 19 | } 20 | 21 | public async Task CompleteItemAsync( 22 | string userId, 23 | Guid itemId, 24 | CancellationToken cancellationToken) 25 | { 26 | return await repository.CompleteItemAsync(userId, itemId, cancellationToken); 27 | } 28 | 29 | public async Task DeleteItemAsync( 30 | string userId, 31 | Guid itemId, 32 | CancellationToken cancellationToken) 33 | { 34 | return await repository.DeleteItemAsync(userId, itemId, cancellationToken); 35 | } 36 | 37 | public async Task GetAsync( 38 | string userId, 39 | Guid itemId, 40 | CancellationToken cancellationToken) 41 | { 42 | var item = await repository.GetItemAsync(userId, itemId, cancellationToken); 43 | 44 | if (item is null) 45 | { 46 | return null; 47 | } 48 | 49 | return MapItem(item); 50 | } 51 | 52 | public async Task GetListAsync( 53 | string userId, 54 | CancellationToken cancellationToken) 55 | { 56 | var result = new TodoListViewModel(); 57 | 58 | if (!string.IsNullOrEmpty(userId)) 59 | { 60 | var items = await repository.GetItemsAsync(userId, cancellationToken); 61 | 62 | foreach (var todo in items) 63 | { 64 | result.Items.Add(MapItem(todo)); 65 | } 66 | } 67 | 68 | return result; 69 | } 70 | 71 | private static TodoItemModel MapItem(TodoItem item) 72 | { 73 | return new TodoItemModel 74 | { 75 | Id = item.Id.ToString(), 76 | IsCompleted = item.CompletedAt.HasValue, 77 | LastUpdated = (item.CompletedAt ?? item.CreatedAt).ToString("u", CultureInfo.InvariantCulture), 78 | Text = item.Text 79 | }; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/TodoApp/TodoApp.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | false 5 | $(NoWarn);CA1050 6 | TodoApp 7 | net9.0 8 | true 9 | latest 10 | TodoApp 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/TodoApp/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "System": "Warning", 6 | "Microsoft": "Warning", 7 | "Microsoft.Hosting.Lifetime": "Information" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/TodoApp/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Warning" 5 | } 6 | }, 7 | "AllowedHosts": "*", 8 | "DataDirectory": "App_Data", 9 | "GitHub": { 10 | "ClientId": "", 11 | "ClientSecret": "", 12 | "EnterpriseDomain": "" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/TodoApp/eslint.config.js: -------------------------------------------------------------------------------- 1 | import stylistic from "@stylistic/eslint-plugin"; 2 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 3 | import jest from "eslint-plugin-jest"; 4 | import globals from "globals"; 5 | import tsParser from "@typescript-eslint/parser"; 6 | import path from "node:path"; 7 | import { fileURLToPath } from "node:url"; 8 | import js from "@eslint/js"; 9 | import { FlatCompat } from "@eslint/eslintrc"; 10 | 11 | const __filename = fileURLToPath(import.meta.url); 12 | const __dirname = path.dirname(__filename); 13 | const compat = new FlatCompat({ 14 | baseDirectory: __dirname, 15 | recommendedConfig: js.configs.recommended, 16 | allConfig: js.configs.all 17 | }); 18 | 19 | export default [...compat.extends("prettier"), { 20 | files: ["**/*.cjs", "**/*.js", "**/*.ts"], 21 | ignores: [ 22 | "bin/", 23 | "node_modules/", 24 | "obj/", 25 | "wwwroot/" 26 | ], 27 | plugins: { 28 | "@stylistic": stylistic, 29 | "@typescript-eslint": typescriptEslint, 30 | jest, 31 | }, 32 | languageOptions: { 33 | globals: { 34 | ...globals.browser, 35 | ...jest.environments.globals.globals, 36 | ...globals.node, 37 | }, 38 | parser: tsParser, 39 | ecmaVersion: 5, 40 | sourceType: "module", 41 | parserOptions: { 42 | project: "./tsconfig.json", 43 | }, 44 | }, 45 | rules: { 46 | "@stylistic/indent": "error", 47 | "@stylistic/member-delimiter-style": "error", 48 | "@stylistic/quotes": ["error", "single"], 49 | "@stylistic/semi": ["error", "always"], 50 | "@stylistic/type-annotation-spacing": "error", 51 | "@typescript-eslint/naming-convention": "error", 52 | "@typescript-eslint/prefer-namespace-keyword": "error", 53 | "brace-style": ["error", "1tbs"], 54 | eqeqeq: ["error", "smart"], 55 | "id-blacklist": [ 56 | "error", 57 | "any", 58 | "Number", 59 | "number", 60 | "String", 61 | "string", 62 | "Boolean", 63 | "boolean", 64 | "Undefined", 65 | "undefined", 66 | ], 67 | "id-match": "error", 68 | "no-eval": "error", 69 | "no-redeclare": "error", 70 | "no-trailing-spaces": "error", 71 | "no-underscore-dangle": "error", 72 | "no-var": "error", 73 | "spaced-comment": ["error", "always", { 74 | markers: ["/"], 75 | }], 76 | }, 77 | }]; 78 | -------------------------------------------------------------------------------- /src/TodoApp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todoapp", 3 | "private": true, 4 | "type": "module", 5 | "version": "1.0.0", 6 | "description": "An ASP.NET Core app for managing Todo items", 7 | "scripts": { 8 | "build": "npm run compile && npm run format && npm run lint && npm test", 9 | "compile": "webpack", 10 | "format": "prettier --write scripts/**/*.ts", 11 | "lint": "eslint scripts", 12 | "test": "jest" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/martincostello/dotnet-minimal-api-integration-testing.git" 17 | }, 18 | "author": "martincostello", 19 | "license": "Apache-2.0", 20 | "devDependencies": { 21 | "@babel/core": "^7.27.4", 22 | "@babel/preset-env": "^7.27.2", 23 | "@stylistic/eslint-plugin": "^4.4.1", 24 | "@typescript-eslint/eslint-plugin": "^8.33.1", 25 | "@typescript-eslint/parser": "^8.32.1", 26 | "eslint": "^9.28.0", 27 | "eslint-config-prettier": "^10.1.5", 28 | "eslint-plugin-jest": "^28.12.0", 29 | "globals": "^16.2.0", 30 | "jest": "^29.7.0", 31 | "moment": "^2.30.1", 32 | "prettier": "^3.5.3", 33 | "ts-jest": "^29.3.4", 34 | "ts-loader": "^9.5.2", 35 | "tsify": "^5.0.4", 36 | "typescript": "^5.8.3", 37 | "webpack": "^5.99.9", 38 | "webpack-cli": "^6.0.1" 39 | }, 40 | "jest": { 41 | "clearMocks": true, 42 | "reporters": [ 43 | "default", 44 | "github-actions" 45 | ], 46 | "transform": { 47 | "^.+\\.ts$": "ts-jest" 48 | }, 49 | "verbose": true 50 | }, 51 | "prettier": { 52 | "arrowParens": "always", 53 | "bracketSpacing": true, 54 | "endOfLine": "auto", 55 | "printWidth": 80, 56 | "quoteProps": "consistent", 57 | "semi": true, 58 | "singleQuote": true, 59 | "tabWidth": 4, 60 | "trailingComma": "es5", 61 | "useTabs": false 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/TodoApp/scripts/ts/client/TodoClient.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | import { TodoItem } from '../models/TodoItem'; 5 | import { TodoList } from '../models/TodoList'; 6 | 7 | export class TodoClient { 8 | async add(text: string): Promise { 9 | const payload = { 10 | text: text, 11 | }; 12 | 13 | const headers = new Headers(); 14 | headers.set('Accept', 'application/json'); 15 | headers.set('Content-Type', 'application/json'); 16 | 17 | const init = { 18 | method: 'POST', 19 | headers: headers, 20 | body: JSON.stringify(payload), 21 | }; 22 | 23 | const response = await fetch('/api/items', init); 24 | 25 | if (!response.ok) { 26 | throw new Error(response.status.toString(10)); 27 | } 28 | 29 | const result = await response.json(); 30 | return result.id; 31 | } 32 | 33 | async complete(id: string): Promise { 34 | const init = { 35 | method: 'POST', 36 | }; 37 | 38 | const url = `/api/items/${encodeURIComponent(id)}/complete`; 39 | 40 | const response = await fetch(url, init); 41 | 42 | if (!response.ok) { 43 | throw new Error(response.status.toString(10)); 44 | } 45 | } 46 | 47 | async delete(id: string): Promise { 48 | const init = { 49 | method: 'DELETE', 50 | }; 51 | 52 | const url = `/api/items/${encodeURIComponent(id)}`; 53 | 54 | const response = await fetch(url, init); 55 | 56 | if (!response.ok) { 57 | throw new Error(response.status.toString(10)); 58 | } 59 | } 60 | 61 | async get(id: string): Promise { 62 | const response = await fetch(`/api/items/${encodeURIComponent(id)}`); 63 | 64 | if (!response.ok) { 65 | throw new Error(response.status.toString(10)); 66 | } 67 | 68 | return await response.json(); 69 | } 70 | 71 | async getAll(): Promise { 72 | const response = await fetch('/api/items'); 73 | 74 | if (!response.ok) { 75 | throw new Error(response.status.toString(10)); 76 | } 77 | 78 | const result: TodoList = await response.json(); 79 | return result.items; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/TodoApp/scripts/ts/main.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | import { TodoApp } from './view/TodoApp'; 5 | 6 | document.addEventListener('DOMContentLoaded', () => { 7 | const app = new TodoApp(); 8 | app.initialize(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/TodoApp/scripts/ts/models/TodoItem.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | export interface TodoItem { 5 | id: string; 6 | text: string; 7 | isCompleted: boolean; 8 | lastUpdated: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/TodoApp/scripts/ts/models/TodoList.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | import { TodoItem } from './TodoItem'; 5 | 6 | export interface TodoList { 7 | items: TodoItem[]; 8 | } 9 | -------------------------------------------------------------------------------- /src/TodoApp/scripts/ts/view/Classes.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | export class Classes { 5 | static item = 'todo-item'; 6 | static hidden = 'd-none'; 7 | } 8 | -------------------------------------------------------------------------------- /src/TodoApp/scripts/ts/view/Elements.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | import { TodoItem } from '../models/TodoItem'; 5 | import { Classes } from './Classes'; 6 | import { Selectors } from './Selectors'; 7 | import { TodoElement } from './TodoElement'; 8 | 9 | export class Elements { 10 | readonly banner: HTMLElement; 11 | readonly createItemButton: HTMLElement; 12 | readonly createItemForm: HTMLElement; 13 | readonly createItemText: HTMLInputElement; 14 | readonly itemList: HTMLElement; 15 | readonly itemTable: HTMLElement; 16 | readonly itemTemplate: HTMLElement; 17 | readonly loader: HTMLElement; 18 | 19 | constructor() { 20 | this.banner = document.getElementById('banner'); 21 | this.createItemButton = document.getElementById('add-new-item'); 22 | this.createItemForm = document.getElementById('add-form'); 23 | this.createItemText = ( 24 | document.getElementById('new-item-text') 25 | ); 26 | this.itemList = document.getElementById('item-list'); 27 | this.itemTable = document.getElementById('item-table'); 28 | this.itemTemplate = document.getElementById('item-template'); 29 | this.loader = document.getElementById('loader'); 30 | } 31 | 32 | createNewItem(item: TodoItem): TodoElement { 33 | // Clone the template and add to the table 34 | const node = this.itemTemplate.cloneNode(true); 35 | this.itemList.appendChild(node); 36 | 37 | // Turn the template into a new item 38 | const element = this.itemList.lastElementChild; 39 | element.classList.add(Classes.item); 40 | 41 | return new TodoElement(element, item); 42 | } 43 | 44 | itemCount(): number { 45 | return this.itemList.querySelectorAll(Selectors.item).length; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/TodoApp/scripts/ts/view/Selectors.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | import { Classes } from './Classes'; 5 | 6 | export class Selectors { 7 | static deleteItem = '.todo-item-delete'; 8 | static itemCompleted = '.todo-item-complete'; 9 | static item = '.' + Classes.item; 10 | static itemText = '.todo-item-text'; 11 | static itemTimestamp = '.todo-item-timestamp'; 12 | } 13 | -------------------------------------------------------------------------------- /src/TodoApp/scripts/ts/view/TodoApp.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | import { describe, expect, test } from '@jest/globals'; 5 | import { TodoApp } from './TodoApp'; 6 | 7 | describe('TodoApp', () => { 8 | test('should be defined', () => { 9 | expect(TodoApp).toBeDefined(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/TodoApp/scripts/ts/view/TodoApp.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | import { TodoClient } from '../client/TodoClient'; 5 | import { TodoItem } from '../models/TodoItem'; 6 | import { Classes } from './Classes'; 7 | import { Elements } from './Elements'; 8 | import { TodoElement } from './TodoElement'; 9 | 10 | export class TodoApp { 11 | private readonly client: TodoClient; 12 | private readonly elements: Elements; 13 | private readonly items: TodoElement[]; 14 | 15 | constructor() { 16 | this.client = new TodoClient(); 17 | this.elements = new Elements(); 18 | this.items = []; 19 | } 20 | 21 | async initialize(): Promise { 22 | // Return if the app is not signed in 23 | if (!this.elements.createItemForm) { 24 | return; 25 | } 26 | 27 | // Disable the default HTML form 28 | this.elements.createItemForm.addEventListener('submit', (event) => { 29 | event.preventDefault(); 30 | return false; 31 | }); 32 | 33 | // Disable/enable the add button when text is absent/present 34 | this.elements.createItemText.addEventListener('input', () => { 35 | if (this.elements.createItemText.value.length === 0) { 36 | this.disable(this.elements.createItemButton); 37 | } else { 38 | this.enable(this.elements.createItemButton); 39 | } 40 | }); 41 | 42 | // Add a new Todo item when the button is clicked 43 | this.elements.createItemButton.addEventListener('click', () => { 44 | this.addNewItem(); 45 | }); 46 | 47 | // Load and render the existing Todo items 48 | const items = await this.client.getAll(); 49 | 50 | items.forEach((item) => { 51 | this.createItem(item); 52 | }); 53 | 54 | // Initialize the UI elements 55 | if (items.length > 0) { 56 | this.show(this.elements.itemTable); 57 | } else { 58 | this.show(this.elements.banner); 59 | } 60 | 61 | this.hide(this.elements.loader); 62 | 63 | // Update any items' relative timestamps every 30 seconds 64 | window.setInterval(() => { 65 | this.items.forEach((item) => { 66 | item.refresh(); 67 | }); 68 | }, 30000); 69 | } 70 | 71 | async addNewItem(): Promise { 72 | this.disable(this.elements.createItemButton); 73 | this.disable(this.elements.createItemText); 74 | 75 | try { 76 | const text = this.elements.createItemText.value; 77 | 78 | // Add the new Todo item and then fetch it 79 | const id = await this.client.add(text); 80 | const item = await this.client.get(id); 81 | 82 | // Render the item 83 | this.createItem(item); 84 | 85 | // Reset the UI for adding another item 86 | this.elements.createItemText.value = ''; 87 | this.hide(this.elements.banner); 88 | this.show(this.elements.itemTable); 89 | } catch { 90 | // Re-enable adding this item if it failed 91 | this.enable(this.elements.createItemButton); 92 | } finally { 93 | this.enable(this.elements.createItemText); 94 | this.elements.createItemText.focus(); 95 | } 96 | } 97 | 98 | createItem(item: TodoItem) { 99 | const element = this.elements.createNewItem(item); 100 | 101 | // Wire-up event handler to complete the Todo item if required 102 | if (!item.isCompleted) { 103 | element.onComplete(async (id) => { 104 | await this.client.complete(id); 105 | }); 106 | } 107 | 108 | // Wire-up event handler to delete the Todo item 109 | element.onDeleting(async (id) => { 110 | await this.client.delete(id); 111 | }); 112 | element.onDeleted(() => { 113 | if (this.elements.itemCount() < 1) { 114 | this.hide(this.elements.itemTable); 115 | this.show(this.elements.banner); 116 | } 117 | 118 | const index = this.items.findIndex( 119 | (item) => item.id() === element.id() 120 | ); 121 | 122 | if (index > -1) { 123 | this.items.splice(index, 1); 124 | } 125 | }); 126 | 127 | element.show(); 128 | 129 | this.items.push(element); 130 | } 131 | 132 | private disable(element: Element) { 133 | element.setAttribute('disabled', ''); 134 | } 135 | 136 | private enable(element: Element) { 137 | element.removeAttribute('disabled'); 138 | } 139 | 140 | private hide(element: Element) { 141 | element.classList.add(Classes.hidden); 142 | } 143 | 144 | private show(element: Element) { 145 | element.classList.remove(Classes.hidden); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/TodoApp/scripts/ts/view/TodoElement.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | import moment from 'moment'; 5 | import { TodoItem } from '../models/TodoItem'; 6 | import { Classes } from './Classes'; 7 | import { Selectors } from './Selectors'; 8 | 9 | export class TodoElement { 10 | private readonly item: TodoItem; 11 | 12 | private readonly completeButton: Element; 13 | private readonly deleteButton: Element; 14 | private readonly itemElement: Element; 15 | private readonly timestampElement: Element; 16 | 17 | private onCompletedHandler: (id: string) => Promise; 18 | private onDeletedHandler: (id: string) => void; 19 | private onDeletingHandler: (id: string) => Promise; 20 | private textElement: Element; 21 | 22 | constructor(element: Element, item: TodoItem) { 23 | this.item = item; 24 | this.itemElement = element; 25 | 26 | this.itemElement.setAttribute('id', item.id); 27 | this.itemElement.setAttribute( 28 | 'data-completed', 29 | item.isCompleted.toString() 30 | ); 31 | this.itemElement.setAttribute('data-id', item.id); 32 | this.itemElement.setAttribute('data-timestamp', item.lastUpdated); 33 | 34 | this.completeButton = element.querySelector(Selectors.itemCompleted); 35 | this.deleteButton = element.querySelector(Selectors.deleteItem); 36 | this.textElement = element.querySelector(Selectors.itemText); 37 | this.timestampElement = element.querySelector(Selectors.itemTimestamp); 38 | 39 | this.textElement.textContent = item.text; 40 | 41 | if (item.isCompleted) { 42 | this.strikethrough(); 43 | } 44 | 45 | this.updateTimestamp(moment(item.lastUpdated)); 46 | 47 | if (!item.isCompleted) { 48 | this.completeButton.classList.remove(Classes.hidden); 49 | this.completeButton.addEventListener('click', () => { 50 | this.onCompleteItem(); 51 | }); 52 | } 53 | 54 | this.deleteButton.addEventListener('click', () => { 55 | this.onDeleteItem(); 56 | }); 57 | } 58 | 59 | id(): string { 60 | return this.item.id; 61 | } 62 | 63 | onComplete(handler: (id: string) => Promise) { 64 | this.onCompletedHandler = handler; 65 | } 66 | 67 | onDeleting(handler: (id: string) => Promise) { 68 | this.onDeletingHandler = handler; 69 | } 70 | 71 | onDeleted(handler: (id: string) => void) { 72 | this.onDeletedHandler = handler; 73 | } 74 | 75 | refresh() { 76 | this.updateTimestamp(moment(this.item.lastUpdated)); 77 | } 78 | 79 | show() { 80 | this.itemElement.classList.remove(Classes.hidden); 81 | } 82 | 83 | private completed(timestamp: moment.Moment) { 84 | this.itemElement.setAttribute('data-completed', 'true'); 85 | this.itemElement.setAttribute( 86 | 'data-timestamp', 87 | timestamp.toISOString() 88 | ); 89 | 90 | this.strikethrough(); 91 | 92 | this.updateTimestamp(timestamp); 93 | } 94 | 95 | private async onCompleteItem(): Promise { 96 | if (this.onCompletedHandler) { 97 | await this.onCompletedHandler(this.item.id); 98 | } 99 | 100 | this.completeButton.classList.add(Classes.hidden); 101 | 102 | const now = moment().milliseconds(0); 103 | this.completed(now); 104 | } 105 | 106 | private async onDeleteItem(): Promise { 107 | if (this.onDeletingHandler) { 108 | await this.onDeletingHandler(this.item.id); 109 | } 110 | 111 | this.itemElement.remove(); 112 | 113 | if (this.onDeletedHandler) { 114 | this.onDeletedHandler(this.item.id); 115 | } 116 | } 117 | 118 | private strikethrough() { 119 | let element = this.textElement; 120 | 121 | const text = element.textContent; 122 | element.textContent = ''; 123 | 124 | const strikethrough = document.createElement('s'); 125 | element.appendChild(strikethrough); 126 | element = strikethrough; 127 | element.textContent = text; 128 | 129 | this.textElement = strikethrough; 130 | } 131 | 132 | private updateTimestamp(timestamp: moment.Moment) { 133 | this.timestampElement.textContent = timestamp.fromNow(); 134 | this.timestampElement.setAttribute('title', timestamp.toLocaleString()); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/TodoApp/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "inlineSources": true, 5 | "inlineSourceMap": true, 6 | "moduleResolution": "Node", 7 | "noEmitOnError": true, 8 | "noImplicitAny": true, 9 | "noImplicitOverride": true, 10 | "noImplicitThis": true, 11 | "outDir": "./wwwroot/static/js", 12 | "removeComments": false, 13 | "sourceMap": false, 14 | "target": "ES2015" 15 | }, 16 | "exclude": [ 17 | "node_modules", 18 | "wwwroot" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/TodoApp/webpack.config.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | module.exports = { 5 | devtool: 'source-map', 6 | entry: './scripts/ts/main.ts', 7 | mode: 'production', 8 | module: { 9 | rules: [ 10 | { 11 | test: /\.ts$/, 12 | use: 'ts-loader', 13 | exclude: /node_modules/, 14 | }, 15 | ], 16 | }, 17 | output: { 18 | filename: 'main.js', 19 | path: path.resolve(__dirname, 'wwwroot', 'static', 'js'), 20 | }, 21 | plugins: [ 22 | new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /en-gb/), 23 | ], 24 | resolve: { 25 | extensions: ['.ts', '.js'], 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /src/TodoApp/wwwroot/css/site.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) Martin Costello, 2021. All rights reserved. 3 | Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 4 | */ 5 | 6 | html { 7 | font-size: 16px; 8 | } 9 | 10 | a { 11 | color: #0060C7; 12 | } 13 | 14 | img.user-profile { 15 | max-height: 30px; 16 | max-width: 30px; 17 | } 18 | 19 | .body-content { 20 | padding-top: 10px; 21 | } 22 | 23 | .navbar-dark .navbar-nav .nav-link { 24 | color: #B0B0B0; 25 | } 26 | -------------------------------------------------------------------------------- /src/TodoApp/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martincostello/dotnet-minimal-api-integration-testing/f1d677c842ecb343866b7abe380ac9d990ff5c6f/src/TodoApp/wwwroot/favicon.ico -------------------------------------------------------------------------------- /src/TodoApp/wwwroot/swagger-ui/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martincostello/dotnet-minimal-api-integration-testing/f1d677c842ecb343866b7abe380ac9d990ff5c6f/src/TodoApp/wwwroot/swagger-ui/favicon-16x16.png -------------------------------------------------------------------------------- /src/TodoApp/wwwroot/swagger-ui/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martincostello/dotnet-minimal-api-integration-testing/f1d677c842ecb343866b7abe380ac9d990ff5c6f/src/TodoApp/wwwroot/swagger-ui/favicon-32x32.png -------------------------------------------------------------------------------- /src/TodoApp/wwwroot/swagger-ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | API Documentation - TodoApp 6 | 7 | 8 | 9 | 27 | 28 | 29 |
30 | 31 | 32 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /startvs.cmd: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | SETLOCAL 3 | 4 | :: This command launches a Visual Studio solution with environment variables required to use a local version of the .NET SDK. 5 | 6 | :: This tells .NET to use the same dotnet.exe that the build script uses. 7 | SET DOTNET_ROOT=%~dp0.dotnet 8 | SET DOTNET_ROOT(x86)=%~dp0.dotnet\x86 9 | 10 | :: Put our local dotnet.exe on PATH first so Visual Studio knows which one to use. 11 | SET PATH=%DOTNET_ROOT%;%PATH% 12 | 13 | SET sln=%~dp0TodoApp.slnx 14 | 15 | IF NOT EXIST "%DOTNET_ROOT%\dotnet.exe" ( 16 | echo The .NET SDK has not yet been installed. Run `%~dp0build.ps1` to install it 17 | exit /b 1 18 | ) 19 | 20 | IF "%VSINSTALLDIR%" == "" ( 21 | start "" "%sln%" 22 | ) else ( 23 | "%VSINSTALLDIR%\Common7\IDE\devenv.com" "%sln%" 24 | ) 25 | -------------------------------------------------------------------------------- /startvscode.cmd: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | SETLOCAL 3 | 4 | :: This command launches Visual Studio Code with environment variables required to use a local version of the .NET SDK. 5 | 6 | :: This tells .NET to use the same dotnet.exe that the build script uses. 7 | SET DOTNET_ROOT=%~dp0.dotnet 8 | SET DOTNET_ROOT(x86)=%~dp0.dotnet\x86 9 | 10 | :: Put our local dotnet.exe on PATH first so Visual Studio Code knows which one to use. 11 | SET PATH=%DOTNET_ROOT%;%PATH% 12 | 13 | :: Sets the Target Framework for Visual Studio Code. 14 | SET TARGET=net9.0 15 | 16 | SET FOLDER=%~1 17 | 18 | IF NOT EXIST "%DOTNET_ROOT%\dotnet.exe" ( 19 | echo The .NET SDK has not yet been installed. Run `%~dp0build.ps1` to install it 20 | exit /b 1 21 | ) 22 | 23 | IF "%FOLDER%"=="" ( 24 | code . 25 | ) else ( 26 | code "%FOLDER%" 27 | ) 28 | 29 | exit /b 1 30 | -------------------------------------------------------------------------------- /tests/TodoApp.Tests/ApiTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Net; 5 | using System.Net.Http.Json; 6 | using System.Text.Json; 7 | using Microsoft.AspNetCore.Http; 8 | using Microsoft.AspNetCore.Mvc; 9 | using Microsoft.AspNetCore.Mvc.Testing; 10 | using TodoApp.Models; 11 | 12 | namespace TodoApp; 13 | 14 | [Collection] 15 | public class ApiTests 16 | { 17 | public ApiTests(TodoAppFixture fixture, ITestOutputHelper outputHelper) 18 | { 19 | Fixture = fixture; 20 | 21 | // Route output from the fixture's logs to xunit's output 22 | OutputHelper = outputHelper; 23 | Fixture.SetOutputHelper(OutputHelper); 24 | } 25 | 26 | private TodoAppFixture Fixture { get; } 27 | 28 | private ITestOutputHelper OutputHelper { get; } 29 | 30 | [Fact] 31 | public async Task Can_Manage_Todo_Items_With_Api() 32 | { 33 | // Arrange 34 | var cancellationToken = TestContext.Current.CancellationToken; 35 | var client = await CreateAuthenticatedClientAsync(); 36 | 37 | // Act - Get all the items 38 | var items = await client.GetFromJsonAsync("/api/items", cancellationToken); 39 | 40 | // Assert - There should be no items 41 | items.ShouldNotBeNull(); 42 | items.Items.ShouldNotBeNull(); 43 | 44 | var beforeCount = items.Items.Count; 45 | 46 | // Arrange 47 | var text = "Buy eggs"; 48 | var newItem = new CreateTodoItemModel { Text = text }; 49 | 50 | // Act - Add a new item 51 | using var createdResponse = await client.PostAsJsonAsync("/api/items", newItem, cancellationToken); 52 | 53 | // Assert - An item was created 54 | createdResponse.StatusCode.ShouldBe(HttpStatusCode.Created); 55 | createdResponse.Headers.Location.ShouldNotBeNull(); 56 | 57 | using var createdJson = await createdResponse.Content.ReadFromJsonAsync(cancellationToken); 58 | 59 | // Arrange - Get the new item's URL and Id 60 | var itemUri = createdResponse.Headers.Location; 61 | var itemId = createdJson!.RootElement.GetProperty("id").GetString(); 62 | 63 | // Act - Get the item 64 | var item = await client.GetFromJsonAsync(itemUri, cancellationToken); 65 | 66 | // Assert - Verify the item was created correctly 67 | item.ShouldNotBeNull(); 68 | item.Id.ShouldBe(itemId); 69 | item.IsCompleted.ShouldBeFalse(); 70 | item.LastUpdated.ShouldNotBeNull(); 71 | item.Text.ShouldBe(text); 72 | 73 | // Act - Mark the item as being completed 74 | using var completedResponse = await client.PostAsJsonAsync(itemUri + "/complete", new { }, cancellationToken); 75 | 76 | // Assert - The item was completed 77 | completedResponse.StatusCode.ShouldBe(HttpStatusCode.NoContent); 78 | 79 | item = await client.GetFromJsonAsync(itemUri, cancellationToken); 80 | 81 | item.ShouldNotBeNull(); 82 | item.Id.ShouldBe(itemId); 83 | item.Text.ShouldBe(text); 84 | item.IsCompleted.ShouldBeTrue(); 85 | 86 | // Act - Get all the items 87 | items = await client.GetFromJsonAsync("/api/items",cancellationToken); 88 | 89 | // Assert - The item was completed 90 | items.ShouldNotBeNull(); 91 | items.Items.ShouldNotBeNull(); 92 | items.Items.Count.ShouldBe(beforeCount + 1); 93 | item = items.Items.Last(); 94 | 95 | item.ShouldNotBeNull(); 96 | item.Id.ShouldBe(itemId); 97 | item.Text.ShouldBe(text); 98 | item.IsCompleted.ShouldBeTrue(); 99 | item.LastUpdated.ShouldNotBeNull(); 100 | 101 | // Act - Delete the item 102 | using var deletedResponse = await client.DeleteAsync(itemUri, cancellationToken); 103 | 104 | // Assert - The item no longer exists 105 | deletedResponse.StatusCode.ShouldBe(HttpStatusCode.NoContent); 106 | 107 | items = await client.GetFromJsonAsync("/api/items", cancellationToken); 108 | 109 | items.ShouldNotBeNull(); 110 | items.Items.ShouldNotBeNull(); 111 | items.Items.Count.ShouldBe(beforeCount); 112 | items.Items.ShouldNotContain(x => x.Id == itemId); 113 | 114 | // Act 115 | using var getResponse = await client.GetAsync(itemUri, cancellationToken); 116 | 117 | // Assert 118 | getResponse.StatusCode.ShouldBe(HttpStatusCode.NotFound); 119 | 120 | var problem = await getResponse.Content.ReadFromJsonAsync(cancellationToken); 121 | 122 | problem.ShouldNotBeNull(); 123 | problem.Status.ShouldBe(StatusCodes.Status404NotFound); 124 | problem.Title.ShouldBe("Not Found"); 125 | problem.Detail.ShouldBe("Item not found."); 126 | problem.Type.ShouldBe("https://tools.ietf.org/html/rfc9110#section-15.5.5"); 127 | problem.Instance.ShouldBeNull(); 128 | } 129 | 130 | [Fact] 131 | public async Task Cannot_Create_Todo_Item_With_No_Text() 132 | { 133 | // Arrange 134 | var cancellationToken = TestContext.Current.CancellationToken; 135 | var client = await CreateAuthenticatedClientAsync(); 136 | var item = new CreateTodoItemModel { Text = string.Empty }; 137 | 138 | // Act 139 | var response = await client.PostAsJsonAsync("/api/items", item, cancellationToken); 140 | 141 | // Assert 142 | response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); 143 | 144 | var problem = await response.Content.ReadFromJsonAsync(cancellationToken); 145 | 146 | problem.ShouldNotBeNull(); 147 | problem.Status.ShouldBe(StatusCodes.Status400BadRequest); 148 | problem.Title.ShouldBe("Bad Request"); 149 | problem.Detail.ShouldBe("No item text specified."); 150 | problem.Type.ShouldBe("https://tools.ietf.org/html/rfc9110#section-15.5.1"); 151 | problem.Instance.ShouldBeNull(); 152 | } 153 | 154 | [Fact] 155 | public async Task Cannot_Complete_Todo_Item_Multiple_Times() 156 | { 157 | // Arrange 158 | var cancellationToken = TestContext.Current.CancellationToken; 159 | var client = await CreateAuthenticatedClientAsync(); 160 | var item = new CreateTodoItemModel { Text = "Something" }; 161 | 162 | using var createdResponse = await client.PostAsJsonAsync("/api/items", item, cancellationToken); 163 | createdResponse.StatusCode.ShouldBe(HttpStatusCode.Created); 164 | createdResponse.Headers.Location.ShouldNotBeNull(); 165 | 166 | var itemUri = createdResponse.Headers.Location; 167 | 168 | using var completedResponse = await client.PostAsJsonAsync(itemUri + "/complete", new { }, cancellationToken); 169 | completedResponse.StatusCode.ShouldBe(HttpStatusCode.NoContent); 170 | 171 | // Act 172 | using var response = await client.PostAsJsonAsync(itemUri + "/complete", new { }, cancellationToken); 173 | 174 | // Assert 175 | response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); 176 | 177 | var problem = await response.Content.ReadFromJsonAsync(cancellationToken); 178 | 179 | problem.ShouldNotBeNull(); 180 | problem.Status.ShouldBe(StatusCodes.Status400BadRequest); 181 | problem.Title.ShouldBe("Bad Request"); 182 | problem.Detail.ShouldBe("Item already completed."); 183 | problem.Type.ShouldBe("https://tools.ietf.org/html/rfc9110#section-15.5.1"); 184 | problem.Instance.ShouldBeNull(); 185 | } 186 | 187 | [Fact] 188 | public async Task Cannot_Complete_Deleted_Todo_Item() 189 | { 190 | // Arrange 191 | var cancellationToken = TestContext.Current.CancellationToken; 192 | var client = await CreateAuthenticatedClientAsync(); 193 | var item = new CreateTodoItemModel { Text = "Something" }; 194 | 195 | using var createdResponse = await client.PostAsJsonAsync("/api/items", item, cancellationToken); 196 | createdResponse.StatusCode.ShouldBe(HttpStatusCode.Created); 197 | createdResponse.Headers.Location.ShouldNotBeNull(); 198 | 199 | var itemUri = createdResponse.Headers.Location; 200 | 201 | using var deletedResponse = await client.DeleteAsync(itemUri, cancellationToken); 202 | deletedResponse.StatusCode.ShouldBe(HttpStatusCode.NoContent); 203 | 204 | // Act 205 | using var response = await client.PostAsJsonAsync(itemUri + "/complete", new { }, cancellationToken); 206 | 207 | // Assert 208 | response.StatusCode.ShouldBe(HttpStatusCode.NotFound); 209 | 210 | var problem = await response.Content.ReadFromJsonAsync(cancellationToken); 211 | 212 | problem.ShouldNotBeNull(); 213 | problem.Status.ShouldBe(StatusCodes.Status404NotFound); 214 | problem.Title.ShouldBe("Not Found"); 215 | problem.Detail.ShouldBe("Item not found."); 216 | problem.Type.ShouldBe("https://tools.ietf.org/html/rfc9110#section-15.5.5"); 217 | problem.Instance.ShouldBeNull(); 218 | } 219 | 220 | [Fact] 221 | public async Task Cannot_Delete_Todo_Item_Multiple_Times() 222 | { 223 | // Arrange 224 | var cancellationToken = TestContext.Current.CancellationToken; 225 | var client = await CreateAuthenticatedClientAsync(); 226 | var item = new CreateTodoItemModel { Text = "Something" }; 227 | 228 | using var createdResponse = await client.PostAsJsonAsync("/api/items", item, cancellationToken); 229 | createdResponse.StatusCode.ShouldBe(HttpStatusCode.Created); 230 | createdResponse.Headers.Location.ShouldNotBeNull(); 231 | 232 | var itemUri = createdResponse.Headers.Location; 233 | 234 | using var deletedResponse = await client.DeleteAsync(itemUri, cancellationToken); 235 | deletedResponse.StatusCode.ShouldBe(HttpStatusCode.NoContent); 236 | 237 | // Act 238 | using var response = await client.DeleteAsync(itemUri, cancellationToken); 239 | 240 | // Assert 241 | response.StatusCode.ShouldBe(HttpStatusCode.NotFound); 242 | 243 | var problem = await response.Content.ReadFromJsonAsync(cancellationToken); 244 | 245 | problem.ShouldNotBeNull(); 246 | problem.Status.ShouldBe(StatusCodes.Status404NotFound); 247 | problem.Title.ShouldBe("Not Found"); 248 | problem.Detail.ShouldBe("Item not found."); 249 | problem.Type.ShouldBe("https://tools.ietf.org/html/rfc9110#section-15.5.5"); 250 | problem.Instance.ShouldBeNull(); 251 | } 252 | 253 | private async Task CreateAuthenticatedClientAsync() 254 | { 255 | var options = new WebApplicationFactoryClientOptions 256 | { 257 | AllowAutoRedirect = true, 258 | BaseAddress = Fixture.ClientOptions.BaseAddress, 259 | HandleCookies = true 260 | }; 261 | 262 | var client = Fixture.CreateClient(options); 263 | 264 | var parameters = Array.Empty>(); 265 | using var content = new FormUrlEncodedContent(parameters); 266 | 267 | // Go through the sign-in flow, which will set 268 | // the authentication cookie on the HttpClient. 269 | using var response = await client.PostAsync("/sign-in", content); 270 | response.IsSuccessStatusCode.ShouldBeTrue(); 271 | 272 | return client; 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /tests/TodoApp.Tests/BrowserFixture.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Runtime.CompilerServices; 5 | using System.Text.Json; 6 | using Microsoft.AspNetCore.WebUtilities; 7 | using Microsoft.Playwright; 8 | 9 | namespace TodoApp; 10 | 11 | public class BrowserFixture( 12 | BrowserFixtureOptions options, 13 | ITestOutputHelper outputHelper) 14 | { 15 | private const string VideosDirectory = "videos"; 16 | 17 | internal static bool IsRunningInGitHubActions { get; } = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")); 18 | 19 | private BrowserFixtureOptions Options { get; } = options; 20 | 21 | public async Task WithPageAsync( 22 | Func action, 23 | [CallerMemberName] string? testName = null) 24 | { 25 | string activeTestName = Options.TestName ?? testName!; 26 | string? videoUrl = null; 27 | 28 | // Start Playwright and load the browser of the specified type 29 | using var playwright = await Playwright.CreateAsync(); 30 | 31 | await using (var browser = await CreateBrowserAsync(playwright, activeTestName)) 32 | { 33 | // Create a new context for the test 34 | var options = CreateContextOptions(); 35 | await using var context = await browser.NewContextAsync(options); 36 | 37 | // Enable generating a trace, if enabled, to use with https://trace.playwright.dev. 38 | if (Options.CaptureTrace) 39 | { 40 | await context.Tracing.StartAsync(new() 41 | { 42 | Screenshots = true, 43 | Snapshots = true, 44 | Sources = true, 45 | Title = activeTestName 46 | }); 47 | } 48 | 49 | // Create a new page for the test to use 50 | var page = await context.NewPageAsync(); 51 | 52 | // Redirect the browser logs to the xunit output 53 | page.Console += (_, e) => outputHelper.WriteLine(e.Text); 54 | page.PageError += (_, e) => outputHelper.WriteLine(e); 55 | 56 | try 57 | { 58 | // Run the test, passing it the page to use 59 | await action(page); 60 | 61 | // Set the BrowserStack test status, if in use 62 | await TrySetSessionStatusAsync(page, "passed"); 63 | } 64 | catch (Exception ex) 65 | { 66 | // Try and capture a screenshot at the point the test failed 67 | await TryCaptureScreenshotAsync(page, activeTestName); 68 | 69 | // Set the BrowserStack test status, if in use 70 | await TrySetSessionStatusAsync(page, "failed", ex.Message); 71 | throw; 72 | } 73 | finally 74 | { 75 | if (Options.CaptureTrace && !Options.UseBrowserStack) 76 | { 77 | string traceName = GenerateFileName(activeTestName, ".zip"); 78 | string path = Path.Combine("traces", traceName); 79 | 80 | await context.Tracing.StopAsync(new() { Path = path }); 81 | } 82 | 83 | videoUrl = await TryCaptureVideoAsync(page, activeTestName); 84 | } 85 | } 86 | 87 | if (videoUrl is not null) 88 | { 89 | // For BrowserStack Automate we need to fetch and save the video after the browser 90 | // is disposed of as we can't get the video while the session is still running. 91 | await CaptureBrowserStackVideoAsync(videoUrl, activeTestName); 92 | } 93 | } 94 | 95 | protected virtual BrowserNewContextOptions CreateContextOptions() 96 | { 97 | var options = new BrowserNewContextOptions 98 | { 99 | IgnoreHTTPSErrors = true, // The test fixture uses a self-signed TLS certificate 100 | Locale = "en-GB", 101 | TimezoneId = "Europe/London" 102 | }; 103 | 104 | 105 | if (Options.CaptureVideo) 106 | { 107 | options.RecordVideoDir = VideosDirectory; 108 | } 109 | 110 | return options; 111 | } 112 | 113 | private async Task CreateBrowserAsync(IPlaywright playwright, string testName) 114 | { 115 | var options = new BrowserTypeLaunchOptions 116 | { 117 | Channel = Options.BrowserChannel 118 | }; 119 | 120 | // Slow down actions and make the DevTools visible by default 121 | // to make it easier to debug the app when debugging locally. 122 | if (System.Diagnostics.Debugger.IsAttached) 123 | { 124 | #pragma warning disable CS0612 125 | options.Devtools = true; 126 | #pragma warning restore CS0612 127 | options.Headless = false; 128 | options.SlowMo = 250; 129 | } 130 | 131 | var browserType = playwright[Options.BrowserType]; 132 | 133 | if (Options.UseBrowserStack && Options.BrowserStackCredentials != default) 134 | { 135 | // Allowed browsers are "chrome", "edge", "playwright-chromium", "playwright-firefox" and "playwright-webkit". 136 | // See https://www.browserstack.com/docs/automate/playwright and 137 | // https://github.com/browserstack/playwright-browserstack/blob/761b35bf79d79ddbfdf518fa6969b409bc42a941/google_search.js 138 | string browser; 139 | 140 | if (!string.IsNullOrEmpty(options.Channel)) 141 | { 142 | browser = options.Channel switch 143 | { 144 | "msedge" => "edge", 145 | _ => options.Channel, 146 | }; 147 | } 148 | else 149 | { 150 | browser = "playwright-" + Options.BrowserType; 151 | } 152 | 153 | // Use the version of the Microsoft.Playwright assembly unless 154 | // explicitly overridden by the options specified by the test. 155 | string playwrightVersion = 156 | Options.PlaywrightVersion ?? 157 | typeof(IBrowser).Assembly.GetName()!.Version!.ToString(3); 158 | 159 | // Supported capabilities and operating systems are documented at the following URLs: 160 | // https://www.browserstack.com/automate/capabilities 161 | // https://www.browserstack.com/list-of-browsers-and-platforms/playwright 162 | var capabilities = new Dictionary() 163 | { 164 | ["browser"] = browser, 165 | ["browserstack.accessKey"] = Options.BrowserStackCredentials.AccessKey, 166 | ["browserstack.username"] = Options.BrowserStackCredentials.UserName, 167 | ["build"] = Options.Build ?? GetDefaultBuildNumber(), 168 | ["client.playwrightVersion"] = playwrightVersion, 169 | ["name"] = testName, 170 | ["os"] = Options.OperatingSystem, 171 | ["os_version"] = Options.OperatingSystemVersion, 172 | ["project"] = Options.ProjectName ?? GetDefaultProject() 173 | }; 174 | 175 | BrowserStackLocalService? localService = null; 176 | 177 | try 178 | { 179 | if (Options.UseBrowserStackLocal) 180 | { 181 | capabilities["browserstack.local"] = "true"; 182 | capabilities["browserstack.localIdentifier"] = Options.BrowserStackLocalOptions?.LocalIdentifier; 183 | 184 | localService = new BrowserStackLocalService( 185 | Options.BrowserStackCredentials.AccessKey, 186 | Options.BrowserStackLocalOptions); 187 | 188 | await localService.StartAsync(); 189 | } 190 | 191 | // Serialize the capabilities as a JSON blob and pass to the 192 | // BrowserStack endpoint in the "caps" query string parameter. 193 | string json = JsonSerializer.Serialize(capabilities); 194 | string wsEndpoint = QueryHelpers.AddQueryString(Options.BrowserStackEndpoint.ToString(), "caps", json); 195 | 196 | var remoteBrowser = await browserType.ConnectAsync(wsEndpoint, new() 197 | { 198 | SlowMo = options.SlowMo, 199 | Timeout = options.Timeout 200 | }); 201 | 202 | // Wrap the IBrowser so we can dispose of the BrowserStack Local 203 | // service when the test has completed, if it was in use at all. 204 | return new RemoteBrowser(remoteBrowser, localService); 205 | } 206 | catch (Exception) 207 | { 208 | localService?.Dispose(); 209 | throw; 210 | } 211 | } 212 | 213 | return await browserType.LaunchAsync(options); 214 | } 215 | 216 | private static string GetDefaultBuildNumber() 217 | { 218 | string? build = Environment.GetEnvironmentVariable("GITHUB_RUN_NUMBER"); 219 | 220 | if (!string.IsNullOrEmpty(build)) 221 | { 222 | return build; 223 | } 224 | 225 | return typeof(BrowserFixture).Assembly.GetName().Version!.ToString(3); 226 | } 227 | 228 | private static string GetDefaultProject() 229 | { 230 | string? project = Environment.GetEnvironmentVariable("GITHUB_REPOSITORY"); 231 | 232 | if (!string.IsNullOrEmpty(project)) 233 | { 234 | return project.Split('/')[1]; 235 | } 236 | 237 | return "TodoApp"; 238 | } 239 | 240 | private string GenerateFileName(string testName, string extension) 241 | { 242 | string browserType = Options.BrowserType; 243 | 244 | if (!string.IsNullOrEmpty(Options.BrowserChannel)) 245 | { 246 | browserType += "_" + Options.BrowserChannel; 247 | } 248 | 249 | string os = 250 | OperatingSystem.IsLinux() ? "linux" : 251 | OperatingSystem.IsMacOS() ? "macos" : 252 | OperatingSystem.IsWindows() ? "windows" : 253 | "other"; 254 | 255 | // Remove characters that are disallowed in file names 256 | browserType = browserType.Replace(':', '_'); 257 | 258 | string utcNow = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd-HH-mm-ss", CultureInfo.InvariantCulture); 259 | return $"{testName}_{browserType}_{os}_{utcNow}{extension}"; 260 | } 261 | 262 | private async Task TryCaptureScreenshotAsync( 263 | IPage page, 264 | string testName) 265 | { 266 | try 267 | { 268 | string fileName = GenerateFileName(testName, ".png"); 269 | string path = Path.Combine("screenshots", fileName); 270 | 271 | await page.ScreenshotAsync(new() { Path = path }); 272 | 273 | outputHelper.WriteLine($"Screenshot saved to {path}."); 274 | } 275 | catch (Exception ex) 276 | { 277 | outputHelper.WriteLine("Failed to capture screenshot: " + ex); 278 | } 279 | } 280 | 281 | private async Task TryCaptureVideoAsync(IPage page, string testName) 282 | { 283 | if (!Options.CaptureVideo || page.Video is null) 284 | { 285 | return null; 286 | } 287 | 288 | try 289 | { 290 | string fileName = GenerateFileName(testName, ".webm"); 291 | string path = Path.Combine(VideosDirectory, fileName); 292 | 293 | if (Options.UseBrowserStack) 294 | { 295 | // BrowserStack Automate does not stop the video until the session has ended, so there 296 | // is no way to get the video to save it to a file, other than after the browser session 297 | // has ended. Instead, we get the URL to the video and then download it afterwards. 298 | // See https://www.browserstack.com/docs/automate/playwright/debug-failed-tests#video-recording. 299 | string session = await page.EvaluateAsync("_ => {}", "browserstack_executor: {\"action\":\"getSessionDetails\"}"); 300 | 301 | using var document = JsonDocument.Parse(session); 302 | return document.RootElement.GetProperty("video_url").GetString(); 303 | } 304 | else 305 | { 306 | await page.CloseAsync(); 307 | await page.Video.SaveAsAsync(path); 308 | } 309 | 310 | outputHelper.WriteLine($"Video saved to {path}."); 311 | } 312 | catch (Exception ex) 313 | { 314 | outputHelper.WriteLine("Failed to capture video: " + ex); 315 | } 316 | 317 | return null; 318 | } 319 | 320 | private async Task CaptureBrowserStackVideoAsync(string videoUrl, string testName) 321 | { 322 | using var client = new HttpClient(); 323 | 324 | for (int i = 0; i < 10; i++) 325 | { 326 | using var response = await client.GetAsync(videoUrl); 327 | 328 | if (response.StatusCode == System.Net.HttpStatusCode.NotFound) 329 | { 330 | // The video may take a few seconds to be available 331 | await Task.Delay(TimeSpan.FromSeconds(2)); 332 | continue; 333 | } 334 | 335 | response.EnsureSuccessStatusCode(); 336 | 337 | string extension = Path.GetExtension(response.Content.Headers.ContentDisposition?.FileName) ?? ".mp4"; 338 | string fileName = GenerateFileName(testName, extension); 339 | string path = Path.Combine(VideosDirectory, fileName); 340 | 341 | if (!Directory.Exists(path)) 342 | { 343 | Directory.CreateDirectory(VideosDirectory); 344 | } 345 | 346 | using var file = File.OpenWrite(path); 347 | 348 | using var stream = await response.Content.ReadAsStreamAsync(); 349 | await stream.CopyToAsync(file); 350 | 351 | outputHelper.WriteLine($"Video saved to {path}."); 352 | break; 353 | } 354 | } 355 | 356 | private async Task TrySetSessionStatusAsync(IPage page, string status, string reason = "") 357 | { 358 | if (!Options.UseBrowserStack) 359 | { 360 | return; 361 | } 362 | 363 | // See https://www.browserstack.com/docs/automate/playwright/mark-test-status#mark-the-status-of-your-playwright-test-using-rest-api-duringafter-the-test-script-run 364 | string json = JsonSerializer.Serialize(new 365 | { 366 | action = "setSessionStatus", 367 | arguments = new 368 | { 369 | status, 370 | reason 371 | } 372 | }); 373 | 374 | await page.EvaluateAsync("_ => {}", $"browserstack_executor: {json}"); 375 | } 376 | 377 | private sealed class RemoteBrowser : IBrowser 378 | { 379 | private readonly IBrowser _browser; 380 | private readonly IDisposable? _localService; 381 | private bool _disposed; 382 | 383 | internal RemoteBrowser(IBrowser browser, IDisposable? localService) 384 | { 385 | _browser = browser; 386 | _localService = localService; 387 | } 388 | 389 | public IBrowserType BrowserType => _browser.BrowserType; 390 | 391 | public IReadOnlyList Contexts => _browser.Contexts; 392 | 393 | public bool IsConnected => _browser.IsConnected; 394 | 395 | public string Version => _browser.Version; 396 | 397 | public event EventHandler Disconnected 398 | { 399 | add => _browser.Disconnected += value; 400 | remove => _browser.Disconnected -= value; 401 | } 402 | 403 | public Task CloseAsync(BrowserCloseOptions? options = null) 404 | => _browser.CloseAsync(); 405 | 406 | public async ValueTask DisposeAsync() 407 | { 408 | try 409 | { 410 | await _browser.DisposeAsync(); 411 | } 412 | finally 413 | { 414 | if (!_disposed) 415 | { 416 | _localService?.Dispose(); 417 | _disposed = true; 418 | } 419 | } 420 | } 421 | 422 | public Task NewBrowserCDPSessionAsync() 423 | => _browser.NewBrowserCDPSessionAsync(); 424 | 425 | public Task NewContextAsync(BrowserNewContextOptions? options = null) 426 | => _browser.NewContextAsync(options); 427 | 428 | public Task NewPageAsync(BrowserNewPageOptions? options = null) 429 | => _browser.NewPageAsync(options); 430 | } 431 | } 432 | -------------------------------------------------------------------------------- /tests/TodoApp.Tests/BrowserFixtureOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace TodoApp; 5 | 6 | public class BrowserFixtureOptions 7 | { 8 | public string BrowserType { get; set; } = Microsoft.Playwright.BrowserType.Chromium; 9 | 10 | public string? BrowserChannel { get; set; } 11 | 12 | // Only record traces and videos in CI to prevent filling 13 | // up the local disk with videos from test runs. 14 | 15 | public bool CaptureTrace { get; set; } = BrowserFixture.IsRunningInGitHubActions; 16 | 17 | public bool CaptureVideo { get; set; } = BrowserFixture.IsRunningInGitHubActions; 18 | 19 | public string? TestName { get; set; } 20 | 21 | public string? Build { get; set; } 22 | 23 | public string? OperatingSystem { get; set; } 24 | 25 | public string? OperatingSystemVersion { get; set; } 26 | 27 | public string? PlaywrightVersion { get; set; } 28 | 29 | public string? ProjectName { get; set; } 30 | 31 | public bool UseBrowserStack { get; set; } 32 | 33 | public bool UseBrowserStackLocal { get; set; } 34 | 35 | public (string UserName, string AccessKey) BrowserStackCredentials { get; set; } 36 | 37 | public BrowserStackLocalOptions? BrowserStackLocalOptions { get; set; } 38 | 39 | public Uri BrowserStackEndpoint { get; set; } = new("wss://cdp.browserstack.com/playwright", UriKind.Absolute); 40 | } 41 | -------------------------------------------------------------------------------- /tests/TodoApp.Tests/BrowserStackLocalOptions.cs: -------------------------------------------------------------------------------- 1 | namespace TodoApp; 2 | 3 | public sealed class BrowserStackLocalOptions 4 | { 5 | //// See https://www.browserstack.com/local-testing/binary-params 6 | 7 | public string? LocalIdentifier { get; set; } 8 | 9 | public string? ProxyHostName { get; set; } 10 | 11 | public string? ProxyPassword { get; set; } 12 | 13 | public int? ProxyPort { get; set; } 14 | 15 | public string? ProxyUserName { get; set; } 16 | 17 | internal static IList BuildCommandLine(string apiKey, BrowserStackLocalOptions? options) 18 | { 19 | ArgumentNullException.ThrowIfNull(apiKey); 20 | 21 | List arguments = 22 | [ 23 | "--key", 24 | apiKey, 25 | "--only-automate" 26 | ]; 27 | 28 | if (!string.IsNullOrWhiteSpace(options?.LocalIdentifier)) 29 | { 30 | arguments.Add("--local-identifier"); 31 | arguments.Add(options.LocalIdentifier); 32 | } 33 | 34 | if (!string.IsNullOrWhiteSpace(options?.ProxyHostName)) 35 | { 36 | if (!options.ProxyPort.HasValue) 37 | { 38 | throw new ArgumentException("No proxy port number specified.", nameof(options)); 39 | } 40 | 41 | arguments.Add("--proxy-host"); 42 | arguments.Add( options.ProxyHostName); 43 | arguments.Add("--proxy-port"); 44 | arguments.Add(options.ProxyPort.Value.ToString(CultureInfo.InvariantCulture)); 45 | 46 | if (!string.IsNullOrWhiteSpace(options.ProxyUserName)) 47 | { 48 | if (string.IsNullOrWhiteSpace(options.ProxyPassword)) 49 | { 50 | throw new ArgumentException("No proxy password specified.", nameof(options)); 51 | } 52 | 53 | arguments.Add("--proxy-user"); 54 | arguments.Add(options.ProxyUserName); 55 | arguments.Add("--proxy-pass"); 56 | arguments.Add(options.ProxyPassword); 57 | } 58 | } 59 | 60 | return arguments; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/TodoApp.Tests/BrowserStackLocalService.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Diagnostics; 5 | using System.IO.Compression; 6 | using System.Text; 7 | 8 | namespace TodoApp; 9 | 10 | internal sealed class BrowserStackLocalService( 11 | string accessKey, 12 | BrowserStackLocalOptions? options) : IDisposable 13 | { 14 | private static readonly SemaphoreSlim _downloadLock = new(1, 1); 15 | private static string? _binaryPath; 16 | private bool _outputRedirected; 17 | private Process? _process; 18 | private bool _disposed; 19 | 20 | ~BrowserStackLocalService() 21 | { 22 | DisposeInternal(); 23 | } 24 | 25 | public async Task StartAsync(CancellationToken cancellationToken = default) 26 | { 27 | var arguments = BrowserStackLocalOptions.BuildCommandLine(accessKey, options); 28 | 29 | // Ensure the binary for the BrowserStack Local proxy service is available 30 | if (_binaryPath is null) 31 | { 32 | // If tests are run in parallel, only download the file once 33 | await _downloadLock.WaitAsync(cancellationToken); 34 | 35 | try 36 | { 37 | #pragma warning disable CA1508 38 | _binaryPath ??= await EnsureBinaryAsync(cancellationToken); 39 | #pragma warning restore CA1508 40 | } 41 | finally 42 | { 43 | _downloadLock.Release(); 44 | } 45 | } 46 | 47 | var startInfo = new ProcessStartInfo 48 | { 49 | CreateNoWindow = true, 50 | FileName = _binaryPath, 51 | RedirectStandardError = false, 52 | RedirectStandardInput = true, 53 | RedirectStandardOutput = true, 54 | UseShellExecute = false, 55 | WindowStyle = ProcessWindowStyle.Hidden 56 | }; 57 | 58 | foreach (string argument in arguments) 59 | { 60 | startInfo.ArgumentList.Add(argument); 61 | } 62 | 63 | _process = Process.Start(startInfo); 64 | 65 | if (_process is null) 66 | { 67 | throw new InvalidOperationException("Failed to start BrowserStack Local service."); 68 | } 69 | 70 | try 71 | { 72 | var stdout = new StringBuilder(); 73 | var tcs = new TaskCompletionSource(); 74 | 75 | void OnOutputDataReceived(object sender, DataReceivedEventArgs e) 76 | { 77 | if (e.Data is not null) 78 | { 79 | stdout.AppendLine(e.Data); 80 | 81 | if (e.Data.Contains("Press Ctrl-C to exit", StringComparison.OrdinalIgnoreCase)) 82 | { 83 | tcs.SetResult(); 84 | } 85 | } 86 | } 87 | 88 | _process.OutputDataReceived += OnOutputDataReceived; 89 | _process.BeginOutputReadLine(); 90 | 91 | _outputRedirected = true; 92 | 93 | if (_process.HasExited) 94 | { 95 | throw new InvalidOperationException( 96 | $"Failed to start process. The process exited with code {_process.ExitCode}. This could be because {GetBinaryName()} requires updating; an updated version can be downloaded from {GetDownloadUri()}."); 97 | } 98 | 99 | // Give the BrowserStackLocal process time to initialize 100 | var timeout = TimeSpan.FromSeconds(15); 101 | 102 | using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); 103 | cts.CancelAfter(timeout); 104 | 105 | try 106 | { 107 | await tcs.Task.WaitAsync(cts.Token); 108 | } 109 | catch (TaskCanceledException) 110 | { 111 | var exception = new InvalidOperationException( 112 | $"Process failed to initialize within {timeout}. This could be because {GetBinaryName()} requires updating; an updated version can be downloaded from {GetDownloadUri()}. Alternatively, it could be caused by a firewall."); 113 | 114 | exception.Data["stdout"] = stdout.ToString(); 115 | 116 | throw exception; 117 | } 118 | 119 | // Once started, we don't need to listen to stdout any more 120 | _process.OutputDataReceived -= OnOutputDataReceived; 121 | stdout.Clear(); 122 | } 123 | catch (Exception) 124 | { 125 | if (_outputRedirected) 126 | { 127 | _process.CancelOutputRead(); 128 | } 129 | 130 | if (!_process.HasExited) 131 | { 132 | try 133 | { 134 | _process.Kill(); 135 | } 136 | catch (InvalidOperationException) 137 | { 138 | } 139 | } 140 | 141 | _process.Dispose(); 142 | _process = null; 143 | 144 | throw; 145 | } 146 | } 147 | 148 | public void Dispose() 149 | { 150 | DisposeInternal(); 151 | GC.SuppressFinalize(this); 152 | } 153 | 154 | private static async Task EnsureBinaryAsync(CancellationToken cancellationToken) 155 | { 156 | try 157 | { 158 | string folderPath = 159 | OperatingSystem.IsWindows() ? 160 | Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) : 161 | Path.GetTempPath(); 162 | 163 | string localCachePath = Path.Combine(folderPath, "_BrowserStackLocal"); 164 | string binaryPath = Path.Combine(localCachePath, GetBinaryName()); 165 | 166 | string zippedBinaryPath = binaryPath + ".zip"; 167 | string cachedETagFileName = zippedBinaryPath + ".ETag"; 168 | 169 | Uri downloadUri = GetDownloadUri(); 170 | 171 | using var client = new HttpClient(); 172 | 173 | string currentETag; 174 | 175 | // Get the current ETag of the ZIP file containing the BrowserStack Local binary 176 | using (var request = new HttpRequestMessage(HttpMethod.Head, downloadUri)) 177 | { 178 | using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); 179 | response.EnsureSuccessStatusCode(); 180 | currentETag = response.Headers.ETag?.Tag ?? string.Empty; 181 | } 182 | 183 | // Is the local version of the tool up-to-date? 184 | bool needToDownload = 185 | !File.Exists(cachedETagFileName) || 186 | !string.Equals(await File.ReadAllTextAsync(cachedETagFileName, Encoding.UTF8, cancellationToken), currentETag, StringComparison.Ordinal); 187 | 188 | if (needToDownload) 189 | { 190 | // Get the latest ZIP file and extract it 191 | using var source = await client.GetStreamAsync(downloadUri, cancellationToken); 192 | 193 | if (Directory.Exists(localCachePath)) 194 | { 195 | Directory.Delete(localCachePath, recursive: true); 196 | } 197 | 198 | Directory.CreateDirectory(localCachePath); 199 | 200 | ZipFile.ExtractToDirectory(source, localCachePath); 201 | await File.WriteAllTextAsync(cachedETagFileName, currentETag, Encoding.UTF8, cancellationToken); 202 | } 203 | 204 | return binaryPath; 205 | } 206 | catch (Exception ex) 207 | { 208 | throw new InvalidOperationException("Failed to download BrowserStack Local binary.", ex); 209 | } 210 | } 211 | 212 | private static string GetBinaryName() 213 | => OperatingSystem.IsWindows() ? "BrowserStackLocal.exe" : "BrowserStackLocal"; 214 | 215 | private static Uri GetDownloadUri() 216 | { 217 | string fileName; 218 | 219 | if (OperatingSystem.IsWindows()) 220 | { 221 | fileName = "BrowserStackLocal-win32.zip"; 222 | } 223 | else if (OperatingSystem.IsMacOS()) 224 | { 225 | fileName = "BrowserStackLocal-darwin-x64.zip"; 226 | } 227 | else if (OperatingSystem.IsLinux()) 228 | { 229 | fileName = Environment.Is64BitOperatingSystem ? 230 | "BrowserStackLocal-linux-x64.zip" : 231 | "BrowserStackLocal-linux-ia32.zip"; 232 | } 233 | else 234 | { 235 | throw new PlatformNotSupportedException("The current platform is not supported."); 236 | } 237 | 238 | return new UriBuilder("https://www.browserstack.com") 239 | { 240 | Path = "browserstack-local/" + fileName 241 | }.Uri; 242 | } 243 | 244 | private void DisposeInternal() 245 | { 246 | if (!_disposed) 247 | { 248 | if (_process != null) 249 | { 250 | if (!_process.HasExited) 251 | { 252 | try 253 | { 254 | if (_outputRedirected) 255 | { 256 | _process.CancelOutputRead(); 257 | } 258 | 259 | try 260 | { 261 | _process.Kill(); 262 | } 263 | catch (InvalidOperationException) 264 | { 265 | } 266 | 267 | if (_process.WaitForExit(10_000)) 268 | { 269 | // It seems to take a second or so to ensure the process 270 | // is stopped so we can delete the extracted binary 271 | Thread.Sleep(TimeSpan.FromSeconds(1)); 272 | } 273 | } 274 | catch (InvalidOperationException) 275 | { 276 | } 277 | catch (SystemException) 278 | { 279 | } 280 | } 281 | 282 | _process.Dispose(); 283 | } 284 | 285 | _disposed = true; 286 | } 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /tests/TodoApp.Tests/HttpRequestInterceptionFilter.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using JustEat.HttpClientInterception; 5 | using Microsoft.Extensions.Http; 6 | 7 | namespace TodoApp; 8 | 9 | // See https://github.com/justeat/httpclient-interception#registering-request-interception-when-using-ihttpclientfactory 10 | 11 | public sealed class HttpRequestInterceptionFilter(HttpClientInterceptorOptions options) : IHttpMessageHandlerBuilderFilter 12 | { 13 | public Action Configure(Action next) 14 | { 15 | return builder => 16 | { 17 | next(builder); 18 | builder.AdditionalHandlers.Add(options.CreateHttpMessageHandler()); 19 | }; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/TodoApp.Tests/HttpServerCollection.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace TodoApp; 5 | 6 | [CollectionDefinition(Name)] 7 | public sealed class HttpServerCollection : ICollectionFixture 8 | { 9 | public const string Name = "TodoApp HTTP server collection"; 10 | } 11 | -------------------------------------------------------------------------------- /tests/TodoApp.Tests/HttpServerFixture.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Security.Cryptography.X509Certificates; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Microsoft.AspNetCore.Hosting.Server; 7 | using Microsoft.AspNetCore.Hosting.Server.Features; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.Hosting; 10 | 11 | namespace TodoApp; 12 | 13 | /// 14 | /// A test server fixture that hosts the application on an HTTP port so 15 | /// that the application can be accessed through a browser for UI tests. 16 | /// 17 | public sealed class HttpServerFixture : TodoAppFixture 18 | { 19 | private bool _disposed; 20 | private IHost? _host; 21 | 22 | public string ServerAddress 23 | { 24 | get 25 | { 26 | EnsureServer(); 27 | return ClientOptions.BaseAddress.ToString(); 28 | } 29 | } 30 | 31 | public override IServiceProvider Services 32 | { 33 | get 34 | { 35 | EnsureServer(); 36 | return _host!.Services!; 37 | } 38 | } 39 | 40 | protected override void ConfigureWebHost(IWebHostBuilder builder) 41 | { 42 | base.ConfigureWebHost(builder); 43 | 44 | // Configure a self-signed TLS certificate for HTTPS 45 | builder.ConfigureKestrel( 46 | serverOptions => serverOptions.ConfigureHttpsDefaults( 47 | httpsOptions => httpsOptions.ServerCertificate = X509CertificateLoader.LoadPkcs12FromFile("localhost-dev.pfx", "Pa55w0rd!"))); 48 | 49 | // Configure the server address for the server to 50 | // listen on for HTTPS requests on a dynamic port. 51 | builder.UseUrls("https://127.0.0.1:0"); 52 | } 53 | 54 | protected override IHost CreateHost(IHostBuilder builder) 55 | { 56 | // Create the host for TestServer now before we 57 | // modify the builder to use Kestrel instead. 58 | var testHost = builder.Build(); 59 | 60 | // Modify the host builder to use Kestrel instead 61 | // of TestServer so we can listen on a real address. 62 | builder.ConfigureWebHost(webHostBuilder => webHostBuilder.UseKestrel()); 63 | 64 | // Create and start the Kestrel server before the test server, 65 | // otherwise due to the way the deferred host builder works 66 | // for minimal hosting, the server will not get "initialized 67 | // enough" for the address it is listening on to be available. 68 | // See https://github.com/dotnet/aspnetcore/issues/33846. 69 | _host = builder.Build(); 70 | _host.Start(); 71 | 72 | // Extract the selected dynamic port out of the Kestrel server 73 | // and assign it onto the client options for convenience so it 74 | // "just works" as otherwise it'll be the default http://localhost 75 | // URL, which won't route to the Kestrel-hosted HTTP server. 76 | var server = _host.Services.GetRequiredService(); 77 | var addresses = server.Features.Get(); 78 | 79 | ClientOptions.BaseAddress = addresses!.Addresses 80 | .Select(x => new Uri(x)) 81 | .Last(); 82 | 83 | // Return the host that uses TestServer, rather than the real one. 84 | // Otherwise the internals will complain about the host's server 85 | // not being an instance of the concrete type TestServer. 86 | // See https://github.com/dotnet/aspnetcore/pull/34702. 87 | return testHost; 88 | } 89 | 90 | protected override void Dispose(bool disposing) 91 | { 92 | base.Dispose(disposing); 93 | 94 | if (!_disposed) 95 | { 96 | if (disposing) 97 | { 98 | _host?.Dispose(); 99 | } 100 | 101 | _disposed = true; 102 | } 103 | } 104 | 105 | private void EnsureServer() 106 | { 107 | if (_host is null) 108 | { 109 | // This forces WebApplicationFactory to bootstrap the server 110 | using var _ = CreateDefaultClient(); 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /tests/TodoApp.Tests/LoopbackOAuthEvents.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Web; 5 | using Microsoft.AspNetCore.Authentication; 6 | using Microsoft.AspNetCore.Authentication.OAuth; 7 | 8 | namespace TodoApp; 9 | 10 | public sealed class LoopbackOAuthEvents : OAuthEvents 11 | { 12 | public override Task RedirectToAuthorizationEndpoint(RedirectContext context) 13 | { 14 | // Extract the state and return URI from the intended 15 | // destination the browser would be directed to. 16 | var query = new UriBuilder(context.RedirectUri).Uri.Query; 17 | var queryString = HttpUtility.ParseQueryString(query); 18 | 19 | var location = queryString["redirect_uri"]; 20 | var state = queryString["state"]; 21 | 22 | queryString.Clear(); 23 | 24 | // Redirect the browser back to the test application with 25 | // the state from the original intended destination. 26 | var code = Guid.NewGuid().ToString(); 27 | 28 | queryString.Add("code", code); 29 | queryString.Add("state", state); 30 | 31 | var builder = new UriBuilder(location!) 32 | { 33 | Query = queryString.ToString() ?? string.Empty 34 | }; 35 | 36 | context.RedirectUri = builder.ToString(); 37 | 38 | return base.RedirectToAuthorizationEndpoint(context); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/TodoApp.Tests/RemoteAuthorizationEventsFilter.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using AspNet.Security.OAuth.GitHub; 5 | using Microsoft.Extensions.Options; 6 | 7 | namespace TodoApp; 8 | 9 | public sealed class RemoteAuthorizationEventsFilter(IHttpClientFactory httpClientFactory) : IPostConfigureOptions 10 | { 11 | public void PostConfigure(string? name, GitHubAuthenticationOptions options) 12 | { 13 | // Use HttpClientFactory for HTTP requests so that the tests 14 | // can intercept the request and return canned responses. 15 | options.Backchannel = httpClientFactory.CreateClient(name ?? string.Empty); 16 | 17 | // Configure the GitHub provider to redirect back to the 18 | // test application, rather than GitHub's own login pages. 19 | options.EventsType = typeof(LoopbackOAuthEvents); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/TodoApp.Tests/ShouldlyTaskExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Shouldly; 5 | 6 | public static class ShouldlyTaskExtensions 7 | { 8 | public static async Task ShouldBe(this Task task, string expected) 9 | { 10 | string actual = await task; 11 | actual.ShouldBe(expected); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/TodoApp.Tests/TodoApp.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | false 4 | $(NoWarn);CA1861 5 | Exe 6 | TodoApp 7 | net9.0 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | true 37 | $([MSBuild]::EnsureTrailingSlash('$(OutputPath)')) 38 | cobertura,json 39 | 40 | 41 | true 42 | HTML 43 | $(ReportGeneratorReportTypes);MarkdownSummaryGitHub 44 | $([System.IO.Path]::Combine($(OutputPath), 'coverage-reports')) 45 | 46 | 47 | 48 | 49 | <_ReportSummaryContent><details><summary>:chart_with_upwards_trend: <b>$(AssemblyName) Code Coverage report</b></summary> 50 | <_ReportSummaryContent>$(_ReportSummaryContent)$([System.Environment]::NewLine) 51 | <_ReportSummaryContent>$(_ReportSummaryContent)$([System.Environment]::NewLine) 52 | <_ReportSummaryContent>$(_ReportSummaryContent)$([System.IO.File]::ReadAllText('$([System.IO.Path]::Combine($(ReportGeneratorTargetDirectory), 'SummaryGithub.md'))')) 53 | <_ReportSummaryContent>$(_ReportSummaryContent)$([System.Environment]::NewLine) 54 | <_ReportSummaryContent>$(_ReportSummaryContent)$([System.Environment]::NewLine) 55 | <_ReportSummaryContent>$(_ReportSummaryContent)</details> 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /tests/TodoApp.Tests/TodoAppCollection.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace TodoApp; 5 | 6 | [CollectionDefinition(Name)] 7 | public sealed class TodoAppCollection : ICollectionFixture 8 | { 9 | public const string Name = "TodoApp server collection"; 10 | } 11 | -------------------------------------------------------------------------------- /tests/TodoApp.Tests/TodoAppFixture.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using AspNet.Security.OAuth.GitHub; 5 | using JustEat.HttpClientInterception; 6 | using MartinCostello.Logging.XUnit; 7 | using Microsoft.AspNetCore.Hosting; 8 | using Microsoft.AspNetCore.Mvc.Testing; 9 | using Microsoft.AspNetCore.TestHost; 10 | using Microsoft.Extensions.Configuration; 11 | using Microsoft.Extensions.DependencyInjection; 12 | using Microsoft.Extensions.Hosting; 13 | using Microsoft.Extensions.Http; 14 | using Microsoft.Extensions.Logging; 15 | using Microsoft.Extensions.Options; 16 | 17 | namespace TodoApp; 18 | 19 | public class TodoAppFixture : WebApplicationFactory, ITestOutputHelperAccessor 20 | { 21 | public TodoAppFixture() 22 | { 23 | // Use HTTPS by default and do not follow 24 | // redirects so they can tested explicitly. 25 | ClientOptions.AllowAutoRedirect = false; 26 | ClientOptions.BaseAddress = new Uri("https://localhost"); 27 | 28 | // Configure HTTP requests that are not intercepted by 29 | // the tests to throw an exception to cause it to fail. 30 | Interceptor = new HttpClientInterceptorOptions().ThrowsOnMissingRegistration(); 31 | } 32 | 33 | public HttpClientInterceptorOptions Interceptor { get; } 34 | 35 | public ITestOutputHelper? OutputHelper { get; set; } 36 | 37 | public void ClearOutputHelper() 38 | => OutputHelper = null; 39 | 40 | public void SetOutputHelper(ITestOutputHelper value) 41 | => OutputHelper = value; 42 | 43 | protected override void ConfigureWebHost(IWebHostBuilder builder) 44 | { 45 | builder.ConfigureAppConfiguration(configBuilder => 46 | { 47 | // Configure the test fixture to write the SQLite database 48 | // to a temporary directory, rather than in App_Data. 49 | var dataDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); 50 | 51 | if (!Directory.Exists(dataDirectory)) 52 | { 53 | Directory.CreateDirectory(dataDirectory); 54 | } 55 | 56 | // Also override the default options for the GitHub OAuth provider 57 | var config = new[] 58 | { 59 | KeyValuePair.Create("DataDirectory", dataDirectory), 60 | KeyValuePair.Create("GitHub:ClientId", "github-id"), 61 | KeyValuePair.Create("GitHub:ClientSecret", "github-secret"), 62 | KeyValuePair.Create("GitHub:EnterpriseDomain", string.Empty) 63 | }; 64 | 65 | configBuilder.AddInMemoryCollection(config); 66 | }); 67 | 68 | // Route the application's logs to the xunit output 69 | builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders().AddXUnit(this)); 70 | 71 | // Configure the correct content root for the static content and Razor pages 72 | builder.UseSolutionRelativeContentRoot(Path.Combine("src", "TodoApp"), "*.slnx"); 73 | 74 | // Configure the application so HTTP requests related to the OAuth flow 75 | // can be intercepted and redirected to not use the real GitHub service. 76 | builder.ConfigureServices(services => 77 | { 78 | services.AddHttpClient(); 79 | 80 | services.AddSingleton( 81 | _ => new HttpRequestInterceptionFilter(Interceptor)); 82 | 83 | services.AddSingleton, RemoteAuthorizationEventsFilter>(); 84 | services.AddScoped(); 85 | }); 86 | 87 | // Configure a bundle of HTTP requests to intercept for the OAuth flow. 88 | Interceptor.RegisterBundle("oauth-http-bundle.json"); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/TodoApp.Tests/TodoPage.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.Playwright; 5 | 6 | namespace TodoApp; 7 | 8 | /// 9 | /// A class representing the Page Object Model for the Todo application. 10 | /// 11 | public class TodoPage(IPage page) 12 | { 13 | public async Task AddItemAsync(string text) 14 | { 15 | await page.FillAsync(Selectors.AddItemText, text); 16 | await page.ClickAsync(Selectors.AddItemButton); 17 | 18 | var input = await page.QuerySelectorAsync(Selectors.AddItemText); 19 | await input!.WaitForElementStateAsync(ElementState.Editable); 20 | } 21 | 22 | public async Task> GetItemsAsync() 23 | { 24 | var elements = await page.QuerySelectorAllAsync(Selectors.TodoItem); 25 | return elements.Select(x => new TodoPageItem(x)).ToArray(); 26 | } 27 | 28 | public async Task SignInAsync() 29 | => await page.ClickAsync(Selectors.SignIn); 30 | 31 | public async Task SignOutAsync() 32 | => await page.ClickAsync(Selectors.SignOut); 33 | 34 | public async Task UserNameAsync() 35 | => await page.InnerTextAsync(Selectors.UserName); 36 | 37 | public async Task WaitForNoItemsAsync() 38 | => await page.WaitForSelectorAsync(Selectors.NoItems); 39 | 40 | public async Task WaitForSignedInAsync() 41 | => await page.WaitForSelectorAsync(Selectors.UserName); 42 | 43 | public async Task WaitForSignedOutAsync() 44 | => await page.WaitForSelectorAsync(Selectors.SignIn); 45 | 46 | public sealed class TodoPageItem(IElementHandle item) 47 | { 48 | public async Task CompleteAsync() 49 | { 50 | var element = await item.QuerySelectorAsync(Selectors.CompleteItem); 51 | await element!.ClickAsync(); 52 | } 53 | 54 | public async Task DeleteAsync() 55 | { 56 | var element = await item.QuerySelectorAsync(Selectors.DeleteItem); 57 | await element!.ClickAsync(); 58 | } 59 | 60 | public async Task TextAsync() 61 | { 62 | var element = await item.QuerySelectorAsync(Selectors.ItemText); 63 | return await element!.InnerTextAsync(); 64 | } 65 | 66 | public async Task LastUpdatedAsync() 67 | { 68 | var element = await item.QuerySelectorAsync(Selectors.ItemTimestamp); 69 | return await element!.InnerTextAsync(); 70 | } 71 | } 72 | 73 | private sealed class Selectors 74 | { 75 | internal const string AddItemButton = "id=add-new-item"; 76 | internal const string AddItemText = "id=new-item-text"; 77 | internal const string CompleteItem = "button[class*='todo-item-complete']"; 78 | internal const string DeleteItem = "button[class*='todo-item-delete']"; 79 | internal const string ItemText = "[class*='todo-item-text']"; 80 | internal const string ItemTimestamp = "[class*='todo-item-timestamp']"; 81 | internal const string NoItems = "id=banner"; 82 | internal const string SignIn = "id=sign-in"; 83 | internal const string SignOut = "id=sign-out"; 84 | internal const string TodoItem = "[class='todo-item']"; 85 | internal const string UserName = "id=user-name"; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tests/TodoApp.Tests/UITests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2021. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.Playwright; 5 | 6 | namespace TodoApp; 7 | 8 | [Collection] 9 | public class UITests : IAsyncLifetime 10 | { 11 | public UITests(HttpServerFixture fixture, ITestOutputHelper outputHelper) 12 | { 13 | Fixture = fixture; 14 | 15 | // Route output from the fixture's logs to xunit's output 16 | OutputHelper = outputHelper; 17 | Fixture.SetOutputHelper(OutputHelper); 18 | } 19 | 20 | private HttpServerFixture Fixture { get; } 21 | 22 | private ITestOutputHelper OutputHelper { get; } 23 | 24 | public static TheoryData Browsers() 25 | { 26 | var browsers = new TheoryData() 27 | { 28 | { BrowserType.Chromium, null }, 29 | { BrowserType.Chromium, "chrome" }, 30 | { BrowserType.Firefox, null }, 31 | }; 32 | 33 | // HACK Skip on macOS. See https://github.com/microsoft/playwright-dotnet/issues/2920. 34 | if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) 35 | { 36 | browsers.Add(BrowserType.Chromium, "msedge"); 37 | } 38 | 39 | if (OperatingSystem.IsMacOS()) 40 | { 41 | browsers.Add(BrowserType.Webkit, null); 42 | } 43 | 44 | return browsers; 45 | } 46 | 47 | [Theory] 48 | [MemberData(nameof(Browsers))] 49 | public async Task Can_Sign_In_And_Manage_Todo_Items(string browserType, string? browserChannel) 50 | { 51 | // Arrange 52 | var options = new BrowserFixtureOptions 53 | { 54 | BrowserType = browserType, 55 | BrowserChannel = browserChannel 56 | }; 57 | 58 | var browser = new BrowserFixture(options, OutputHelper); 59 | await browser.WithPageAsync(async page => 60 | { 61 | // Load the application 62 | await page.GotoAsync(Fixture.ServerAddress); 63 | await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded); 64 | 65 | var app = new TodoPage(page); 66 | 67 | // Act - Sign in 68 | await app.SignInAsync(); 69 | 70 | // Assert 71 | await app.WaitForSignedInAsync(); 72 | await app.UserNameAsync().ShouldBe("John Smith"); 73 | 74 | // Arrange - Wait for list to be ready 75 | await app.WaitForNoItemsAsync(); 76 | 77 | // Act - Add an item 78 | await app.AddItemAsync("Buy cheese"); 79 | 80 | // Assert 81 | var items = await app.GetItemsAsync(); 82 | items.Count.ShouldBe(1); 83 | 84 | await items[0].TextAsync().ShouldBe("Buy cheese"); 85 | await items[0].LastUpdatedAsync().ShouldBe("a few seconds ago"); 86 | 87 | // Act - Add another item 88 | await app.AddItemAsync("Buy eggs"); 89 | 90 | // Assert 91 | items = await app.GetItemsAsync(); 92 | items.Count.ShouldBe(2); 93 | 94 | await items[0].TextAsync().ShouldBe("Buy cheese"); 95 | await items[1].TextAsync().ShouldBe("Buy eggs"); 96 | 97 | // Act - Delete an item and complete an item 98 | await items[0].DeleteAsync(); 99 | await items[1].CompleteAsync(); 100 | 101 | await Task.Delay(TimeSpan.FromSeconds(0.5), TestContext.Current.CancellationToken); 102 | 103 | // Assert 104 | items = await app.GetItemsAsync(); 105 | items.Count.ShouldBe(1); 106 | 107 | await items[0].TextAsync().ShouldBe("Buy eggs"); 108 | 109 | // Act - Delete the remaining item 110 | await items[0].DeleteAsync(); 111 | 112 | // Assert 113 | await app.WaitForNoItemsAsync(); 114 | 115 | // Act - Sign out 116 | await app.SignOutAsync(); 117 | 118 | // Assert 119 | await app.WaitForSignedOutAsync(); 120 | }); 121 | } 122 | 123 | public ValueTask InitializeAsync() 124 | { 125 | InstallPlaywright(); 126 | return ValueTask.CompletedTask; 127 | } 128 | 129 | public ValueTask DisposeAsync() 130 | { 131 | GC.SuppressFinalize(this); 132 | return ValueTask.CompletedTask; 133 | } 134 | 135 | private static void InstallPlaywright() 136 | { 137 | int exitCode = Microsoft.Playwright.Program.Main(["install"]); 138 | 139 | if (exitCode != 0) 140 | { 141 | throw new InvalidOperationException($"Playwright exited with code {exitCode}"); 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /tests/TodoApp.Tests/localhost-dev.pfx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martincostello/dotnet-minimal-api-integration-testing/f1d677c842ecb343866b7abe380ac9d990ff5c6f/tests/TodoApp.Tests/localhost-dev.pfx -------------------------------------------------------------------------------- /tests/TodoApp.Tests/oauth-http-bundle.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/justeat/httpclient-interception/main/src/HttpClientInterception/Bundles/http-request-bundle-schema.json", 3 | "id": "oauth", 4 | "version": 1, 5 | "comment": "HTTP bundle for GitHub OAuth authentication.", 6 | "items": [ 7 | { 8 | "comment": "Token resource for GitHub login", 9 | "uri": "https://github.com/login/oauth/access_token", 10 | "method": "POST", 11 | "contentFormat": "json", 12 | "contentJson": { 13 | "access_token": "secret-access-token", 14 | "token_type": "bearer", 15 | "scope": "repo,gist,user:email" 16 | } 17 | }, 18 | { 19 | "comment": "User information resource for GitHub login", 20 | "uri": "https://api.github.com/user", 21 | "contentFormat": "json", 22 | "contentJson": { 23 | "login": "john-smith", 24 | "id": 1, 25 | "type": "User", 26 | "name": "John Smith", 27 | "company": "GitHub", 28 | "location": "London, UK", 29 | "html_url": "https://github.com/john-smith", 30 | "avatar_url": "https://avatars.githubusercontent.com/u/9141961?s=60&v=4" 31 | } 32 | }, 33 | { 34 | "comment": "Email resource for GitHub login", 35 | "uri": "https://api.github.com/user/emails", 36 | "contentFormat": "json", 37 | "contentJson": [ 38 | { 39 | "email": "github@john-smith.local", 40 | "verified": true, 41 | "primary": true, 42 | "visibility": "public" 43 | } 44 | ] 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /tests/TodoApp.Tests/xunit.runner.json: -------------------------------------------------------------------------------- 1 | { 2 | "methodDisplay": "method" 3 | } 4 | --------------------------------------------------------------------------------