[
  {
    "path": ".github/CODEOWNERS",
    "content": "\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/BUG_REPORT.yml",
    "content": "name: 🐞 Bug report\ndescription: Report a bug in the RealWorld project\ntitle: '[Bug]: '\nlabels:\n  - bug\nbody:\n  - type: dropdown\n    attributes:\n      label: Relevant scope\n      description: What is the scope of this request?\n      options:\n        - Frontend specs\n        - Backend specs\n        - Deployed demo\n        - 'Other: describe below'\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Description\n      description: A clear and concise description of the problem\n    validations:\n      required: true\n  - type: markdown\n    attributes:\n      value: >-\n        This template was generated with [Issue Forms\n        Creator](https://www.issue-forms-creator.app/)\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml",
    "content": "name: 🚀 Feature request\ndescription: Suggest a feature for RealWorld project\ntitle: '[Feature Request]:'\nbody:\n  - type: markdown\n    attributes:\n      value: '# Feature Request'\n  - type: dropdown\n    attributes:\n      label: Relevant Scope\n      description: What is the scope of this request?\n      options:\n        - Frontend specs\n        - Backend specs\n        - 'Other: describe below'\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Description\n      description: ' <!-- ✍️--> '\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Describe the solution you'd like\n      description: If you have a solution in mind, please describe it.\n  - type: textarea\n    attributes:\n      label: Describe alternatives you've considered\n      description: Have you considered any alternative solutions or workarounds?\n  - type: markdown\n    attributes:\n      value: >-\n        This template was generated with [Issue Forms\n        Creator](https://www.issue-forms-creator.app/)\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: github-actions\n    directory: '/'\n    schedule:\n      interval: weekly\n    open-pull-requests-limit: 10\n\n  - package-ecosystem: npm\n    directory: '/'\n    schedule:\n      interval: weekly\n    open-pull-requests-limit: 10\n"
  },
  {
    "path": ".github/workflows/bruno-check.yml",
    "content": "name: 'Bruno Check'\n\non:\n  push:\n  pull_request:\n\njobs:\n  bruno-check:\n    name: Verify Bruno collection is up-to-date\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n\n      - name: Setup Bun\n        uses: oven-sh/setup-bun@v2\n\n      - name: Check Bruno collection is up-to-date\n        run: make bruno-check\n"
  },
  {
    "path": ".github/workflows/codeql.yml",
    "content": "name: 'CodeQL'\n\non:\n  workflow_dispatch:\n  schedule:\n    - cron: '24 3 * * 3'\n\njobs:\n  analyze:\n    name: Analyze\n    runs-on: ubuntu-latest\n    permissions:\n      actions: read\n      contents: read\n      security-events: write\n\n    strategy:\n      fail-fast: false\n      matrix:\n        language: ['javascript']\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n\n      - name: Initialize CodeQL\n        uses: github/codeql-action/init@v4\n        with:\n          languages: ${{ matrix.language }}\n\n      - name: Perform CodeQL Analysis\n        uses: github/codeql-action/analyze@v4\n        with:\n          category: '/language:${{matrix.language}}'\n"
  },
  {
    "path": ".github/workflows/deploy-docs.yml",
    "content": "name: Deploy Documentation\n\non:\n  push:\n    branches: [main]\n    paths:\n      - 'docs/**'\n      - '.github/workflows/deploy-docs.yml'\n  workflow_dispatch:  # allow manual trigger\n\npermissions:\n  contents: read\n  pages: write\n  id-token: write\n\nconcurrency:\n  group: pages\n  cancel-in-progress: false\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Setup Bun\n        uses: oven-sh/setup-bun@v2\n        with:\n          bun-version: latest\n\n      - name: Setup Pages\n        uses: actions/configure-pages@v4\n\n      - name: Install dependencies\n        run: bun install\n        working-directory: ./docs\n\n      - name: Build documentation\n        run: bun run build\n        working-directory: ./docs\n\n      - name: Upload artifact\n        uses: actions/upload-pages-artifact@v4\n        with:\n          path: './docs/dist'\n\n  deploy:\n    environment:\n      name: github-pages\n      url: ${{ steps.deployment.outputs.page_url }}\n    runs-on: ubuntu-latest\n    needs: build\n    steps:\n      - name: Deploy to GitHub Pages\n        id: deployment\n        uses: actions/deploy-pages@v4\n"
  },
  {
    "path": ".github/workflows/spammy-guardian.yml",
    "content": "name: Spammy Guardian\non:\n  workflow_dispatch:\n    inputs:\n      issueId:\n        description: 'id of the issue to test againt'\n        required: true\n  issue_comment:\n  issues:\n    types: [opened]\njobs:\n  spammy-guardian:\n    runs-on: ubuntu-latest\n    if: ${{ github.actor != 'dependabot[bot]' || github.actor != 'netlify[bot]' }}\n    steps:\n      - uses: kerhub/spammy-guardian@fa79bcda24df6dae5b93285e1749e59c77add4bd\n        with:\n          token: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# Dependencies\nnode_modules\n.pnp\n.pnp.js\n\n# Local env files\n.env\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\n# Testing\ncoverage\n\n# Turbo\n.turbo\n\n# Vercel\n.vercel\n\n# Build Outputs\n.next/\n.nitro/\nout/\nbuild\ndist\n\n\n# Debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Misc\n.DS_Store\n*.pem\n\n\n# IDEs\n.idea\n\n# Python\n__pycache__\n\n# Temp files\n.tmp\n\n# Local db\ndev.db\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to RealWorld\n\nWe would love for you to contribute to RealWorld and help make it even better than it is\ntoday! As a contributor, here are the guidelines we would like you to follow:\n\n- [Question or Problem?](#question)\n- [Issues and Bugs](#issue)\n- [Feature Requests](#feature)\n- [Submission Guidelines](#submit)\n- [Coding Rules](#rules)\n- [Commit Message Guidelines](#commit)\n\n## <a name=\"question\"></a> Got a Question or Problem?\n\nDo not open issues for general support questions as we want to keep GitHub issues for bug reports and feature requests.  \nFor open discussions, we encourage you to use the [Github Discussions][github-discussions] channels.\n\n## <a name=\"issue\"></a> Interested in creating Conduit for your framework?\n\nTo create an official implementation of Conduit, check out our [Github Discussions](https://github.com/realworld-apps/realworld/discussions/categories/wip-implementations) and see if anyone else has requested and/or is already working on your framework.\nIf not, feel free to start working on one!\n\nStart [here][github-spec]!\n\n## <a name=\"issue\"></a> Found a Bug?\n\nIf you find a bug in the project, you can help us by\n[submitting an issue][github-issue] to our [GitHub Repository][github]. Even better, you can\n[submit a Pull Request](#submit-pr) with a fix.\n\n## <a name=\"feature\"></a> Missing a Feature?\n\nYou can _request_ a new feature by [submitting an issue](#submit-issue) to our GitHub\nrepository.\n\nIf you would like to _implement_ a new feature, please submit an issue with\na proposal for your work **FIRST**, to be sure that we can use it.\nPlease consider what kind of change it is:\n\n- For a **Major Feature**, first open an issue and outline your proposal so that it can be\n  discussed. This will also allow us to better coordinate our efforts, prevent duplication of work,\n  and help you to craft the change so that it is successfully accepted into the project.\n- **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr).\n\n## <a name=\"submit\"></a> Submission Guidelines\n\n### <a name=\"submit-issue\"></a> Submitting an Issue\n\nBefore you submit an issue, please search the issue tracker, maybe an issue for your problem already exists and the discussion might inform you of workarounds readily available.\n\nYou can file new issues by selecting from our [new issue templates][github-choose] and filling out the issue template.\n\n### <a name=\"submit-pr\"></a> Submitting a Pull Request (PR)\n\nBefore you submit your Pull Request (PR) consider the following guidelines:\n\n1. Search [GitHub](https://github.com/realworld-apps/realworld/pulls) for an open or closed PR\n   that relates to your submission. You don't want to duplicate effort.\n1. Be sure that an issue describes the problem you're fixing, or documents the design for the feature you'd like to add.\n   Discussing the design up front helps to ensure that we're ready to accept your work.\n1. Fork the realworld-apps/realworld repo.\n1. Make your changes in a new git branch:\n\n   ```bash\n   git checkout -b my-fix-branch master\n   ```\n\n1. Create your patch.\n\n1. Commit your changes using a descriptive commit message that follows our\n   [commit message conventions](#commit).\n\n1. Push your branch to GitHub:\n\n   ```bash\n   git push origin my-fix-branch\n   ```\n\n1. In GitHub, send a pull request to `realworld:master`.\n\n- If we suggest changes then:\n\n  - Make the required updates.\n  - Rebase your branch and force push to your GitHub repository (this will update your Pull Request):\n\n    ```bash\n    git rebase master -i\n    git push -f\n    ```\n\nThat's it! Thank you for your contribution!\n\n#### After your pull request is merged\n\nAfter your pull request is merged, you can safely delete your branch and pull the changes\nfrom the master (upstream) repository:\n\n- Delete the remote branch on GitHub either through the GitHub web UI or your local shell as follows:\n\n  ```bash\n  git push origin --delete my-fix-branch\n  ```\n\n- Check out the master branch:\n\n  ```bash\n  git checkout master -f\n  ```\n\n- Delete the local branch:\n\n  ```bash\n  git branch -D my-fix-branch\n  ```\n\n- Update your master with the latest upstream version:\n\n  ```bash\n  git pull --ff upstream master\n  ```\n\n## <a name=\"commit\"></a> Commit Message Guidelines\n\n> These guidelines have been added to the project starting from February 2025\n\nWe have very precise rules over how our git commit messages can be formatted. This leads to **more\nreadable messages** that are easy to follow when looking through the **project history**.\n\n### Commit Message Format\n\nEach commit message consists of a **header**, a **body** and a **footer**. The header has a special\nformat that includes a **type**, a **scope** and a **subject**:\n\n```\n<type>(<scope>): <subject>\n<BLANK LINE>\n<body>\n<BLANK LINE>\n<footer>\n```\n\nThe **header** is mandatory and the **scope** of the header is optional.\n\nAny line of the commit message cannot be longer 100 characters! This allows the message to be easier\nto read on GitHub as well as in various git tools.\n\nThe footer should contain a [closing reference to an issue](https://help.github.com/articles/closing-issues-via-commit-messages/) if any.\n\nSamples:\n\n```\ndocs(changelog): update changelog to beta.5\n```\n\n```\nfix(release): need to depend on latest ng-lib\n\nThe version in our package.json gets copied to the one we publish, and users need the latest of these.\n```\n\n### Type\n\nMust be one of the following:\n\n- **docs**: Documentation only changes\n- **feat**: A new feature\n- **fix**: A bug fix\n\n### Scope\n\nThe scope should be the name of the npm package affected (as perceived by the person reading the changelog generated from commit messages).\n\nThe following is the list of supported scopes:\n\n- **specs**\n- **project**\n\n### Subject\n\nThe subject contains a succinct description of the change:\n\n- use the imperative, present tense: \"change\" not \"changed\" nor \"changes\"\n- don't capitalize the first letter\n- no dot (.) at the end\n\n### Body\n\nJust as in the **subject**, use the imperative, present tense: \"change\" not \"changed\" nor \"changes\".\nThe body should include the motivation for the change and contrast this with previous behavior.\n\n### Footer\n\nThe footer should contain any information about **Breaking Changes** and is also the place to\nreference GitHub issues that this commit **Closes**.\n\n**Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this.\n\nSamples :\n\n```\nClose #394\n```\n\n```\nBREAKING CHANGE:\nchange login route to /users/login\n```\n\n[github]: https://github.com/realworld-apps/realworld\n[github-issue]: https://github.com/realworld-apps/realworld/issues/new?assignees=&labels=bug&template=---bug-report.md&title=\n[github-feature]: https://github.com/realworld-apps/realworld/issues/new?assignees=&labels=enhancement&template=---feature-request.md&title=\n[github-choose]: https://github.com/realworld-apps/realworld/issues/new/choose\n[github-discussions]: https://github.com/realworld-apps/realworld/discussions\n[github-spec]: https://github.com/realworld-apps/realworld/tree/master/spec\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2021 Thinkster\nCopyright (c) 2026 c4ffein\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n---\n\nNote: Third-party framework logos in assets/media/frameworks.svg are NOT covered\nby this license. See docs/non-included/LICENSES_LOGOS.md for their respective\nlicenses and attribution.\n"
  },
  {
    "path": "Makefile",
    "content": ".PHONY: help \\\n\tbruno-generate \\\n\tbruno-check \\\n\tdocumentation-setup \\\n\tdocumentation-dev \\\n\tdocumentation-dev-host \\\n\tdocumentation-build \\\n\tdocumentation-preview \\\n\tdocumentation-clean\n\nhelp:\n\t@echo \"Bruno Collection:\"\n\t@echo \"  bruno-generate\"\n\t@echo \"  bruno-check\"\n\t@echo \"\"\n\t@echo \"Documentation:\"\n\t@echo \"  documentation-setup\"\n\t@echo \"  documentation-dev\"\n\t@echo \"  documentation-dev-host\"\n\t@echo \"  documentation-build\"\n\t@echo \"  documentation-preview\"\n\t@echo \"  documentation-clean\"\n\n########################\n# Bruno Collection\n\nbruno-generate:\n\tbun specs/api/hurl-to-bruno.js\n\nbruno-check:\n\tbun specs/api/hurl-to-bruno.js --check\n\n########################\n# Documentation\n\ndocumentation-setup:\n\tcd docs && bun install\n\ndocumentation-dev:\n\tcd docs && bun run dev\n\ndocumentation-dev-host:\n\tcd docs && bun run dev --host\n\ndocumentation-build:\n\tcd docs && bun run build\n\ndocumentation-preview:\n\tcd docs && bun run preview\n\ndocumentation-clean:\n\trm -rf docs/.astro docs/dist docs/node_modules\n"
  },
  {
    "path": "README.md",
    "content": "![RealWorld Example Applications](assets/media/realworld-dual-mode.png)\n\n<p align=\"center\" style=\"margin-top: 30px;\">\n  <img src=\"assets/media/frameworks.svg\" alt=\"Frontend and Backend Frameworks\" width=\"720\"/>\n</p>\n\n### See how [_the exact same_ Medium.com clone](https://demo.realworld.show) is built using different [frontends](https://codebase.show/projects/realworld?category=frontend) and [backends](https://codebase.show/projects/realworld?category=backend)\n\nYou can combine any frontend with any backend, because **they all adhere to the same API spec**\n\nWhile most \"todo\" demos provide an excellent cursory glance at a framework's capabilities, they typically don't convey the knowledge & perspective required to actually build _real_ applications with it.\n\n**RealWorld** solves this by allowing you to choose any frontend (React, Angular, & more) and any backend (Node, Django, & more).\n\n_Read the [full blog post announcing RealWorld on Medium.](https://medium.com/@ericsimons/introducing-realworld-6016654d36b5)_\n\nJoin us on [GitHub Discussions!](https://github.com/realworld-apps/realworld/discussions) 🎉\n\n# Implementations\n\nOver 100 implementations have been created using various languages, libraries, and frameworks.\n\nExplore them on [**CodebaseShow**](https://codebase.show/projects/realworld).\n\n## Spec-compliant backends\n\nThese backends pass the full [API spec test suite](specs/api/):\n\n- [**Nitro + Prisma + Zod**](https://github.com/realworld-apps/nitro-prisma-zod-realworld-example-app) — TypeScript\n- [**Django Ninja**](https://github.com/c4ffein/realworld-django-ninja) — Python\n\n# Create a new implementation\n\n[**Create a new implementation >>>**](https://docs.realworld.show/implementation-creation/introduction)\n\nOr you can [view upcoming implementations (WIPs)](https://github.com/realworld-apps/realworld/discussions/categories/wip-implementations).\n\n# Learn more\n\n- [Documentation introduction](https://docs.realworld.show/introduction/)\n- Every tutorial is built against the same [API spec](specs/api/) to ensure modularity of every frontend & backend\n- A shared [CSS theme](assets/theme/styles.css) is provided to build frontend implementations with identical UI/UX\n- A shared [E2E test suite](specs/e2e/) is available to validate frontend implementations\n- There is a hosted version of the backend API available for public usage at [api.realworld.show](https://api.realworld.show) (with strong account isolation), no API keys are required\n- There is an angular frontend plugged to this backend available at [demo.realworld.show](https://demo.realworld.show)\n- Interested in creating a new RealWorld stack? View our [starter guide & spec](https://docs.realworld.show/implementation-creation/introduction)\n\n# Logo Attribution\n\nSee [LICENSES_LOGOS.md](docs/non-included/LICENSES_LOGOS.md) for framework logo licensing and attribution details.\n\n# Active Maintainers\n\n- **[c4ffein](https://github.com/c4ffein) - Maintainer** - currently maintains the [demo website](https://demo.realworld.show)\n- **[Manuel Vila](https://github.com/mvila) - Maintainer** - creator of the [Layr framework](https://layrjs.com) and the [CodebaseShow website](https://codebase.show/)\n"
  },
  {
    "path": "assets/media/conduit-logo.svg.generate.ts",
    "content": "import opentype from 'opentype.js';\nimport { mkdirSync } from 'fs';\n\nprocess.chdir(import.meta.dir);\n\nconst FONT_URL = 'https://fonts.gstatic.com/s/caudex/v19/esDT311QOP6BJUrwdteUkp8G.ttf';\nconst TMP_DIR = '.tmp';\nconst FONT_PATH = `${TMP_DIR}/caudex-bold.ttf`;\n\n// Ensure .tmp directory exists and download font if needed\nmkdirSync(TMP_DIR, { recursive: true });\nconst fontFile = Bun.file(FONT_PATH);\nif (!(await fontFile.exists())) {\n  console.log('Downloading Caudex Bold...');\n  const res = await fetch(FONT_URL);\n  await Bun.write(FONT_PATH, res);\n}\n\nconst font = opentype.loadSync(FONT_PATH);\nconst text = 'Conduit';\nconst fontSize = 48;\n\nconst path = font.getPath(text, 0, 0, fontSize);\nconst bb = path.getBoundingBox();\n\nconst padding = 2;\nconst x = bb.x1 - padding;\nconst y = bb.y1 - padding;\nconst width = bb.x2 - bb.x1 + padding * 2;\nconst height = bb.y2 - bb.y1 + padding * 2;\n\nconst svg = `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"${x} ${y} ${width} ${height}\" fill=\"#222\">\n  ${path.toSVG(2)}\n</svg>`;\n\nconst outPath = 'conduit-logo.svg';\nawait Bun.write(outPath, svg);\nconsole.log(`Written to ${outPath} (${width.toFixed(1)} x ${height.toFixed(1)})`);\n"
  },
  {
    "path": "assets/media/mobile_icons/ios/AppIcon.appiconset/Contents.json",
    "content": "{\n  \"images\": [\n    {\n      \"idiom\": \"iphone\",\n      \"size\": \"20x20\",\n      \"scale\": \"2x\",\n      \"filename\": \"Icon-App-20x20@2x.png\"\n    },\n    {\n      \"idiom\": \"iphone\",\n      \"size\": \"20x20\",\n      \"scale\": \"3x\",\n      \"filename\": \"Icon-App-20x20@3x.png\"\n    },\n    {\n      \"idiom\": \"iphone\",\n      \"size\": \"29x29\",\n      \"scale\": \"1x\",\n      \"filename\": \"Icon-App-29x29@1x.png\"\n    },\n    {\n      \"idiom\": \"iphone\",\n      \"size\": \"29x29\",\n      \"scale\": \"2x\",\n      \"filename\": \"Icon-App-29x29@2x.png\"\n    },\n    {\n      \"idiom\": \"iphone\",\n      \"size\": \"29x29\",\n      \"scale\": \"3x\",\n      \"filename\": \"Icon-App-29x29@3x.png\"\n    },\n    {\n      \"idiom\": \"iphone\",\n      \"size\": \"40x40\",\n      \"scale\": \"1x\",\n      \"filename\": \"Icon-App-40x40@1x.png\"\n    },\n    {\n      \"idiom\": \"iphone\",\n      \"size\": \"40x40\",\n      \"scale\": \"2x\",\n      \"filename\": \"Icon-App-40x40@2x.png\"\n    },\n    {\n      \"idiom\": \"iphone\",\n      \"size\": \"40x40\",\n      \"scale\": \"3x\",\n      \"filename\": \"Icon-App-40x40@3x.png\"\n    },\n    {\n      \"idiom\": \"iphone\",\n      \"size\": \"57x57\",\n      \"scale\": \"1x\",\n      \"filename\": \"Icon-App-57x57@1x.png\"\n    },\n    {\n      \"idiom\": \"iphone\",\n      \"size\": \"57x57\",\n      \"scale\": \"2x\",\n      \"filename\": \"Icon-App-57x57@2x.png\"\n    },\n    {\n      \"idiom\": \"iphone\",\n      \"size\": \"60x60\",\n      \"scale\": \"1x\",\n      \"filename\": \"Icon-App-60x60@1x.png\"\n    },\n    {\n      \"idiom\": \"iphone\",\n      \"size\": \"60x60\",\n      \"scale\": \"2x\",\n      \"filename\": \"Icon-App-60x60@2x.png\"\n    },\n    {\n      \"idiom\": \"iphone\",\n      \"size\": \"60x60\",\n      \"scale\": \"3x\",\n      \"filename\": \"Icon-App-60x60@3x.png\"\n    },\n    {\n      \"idiom\": \"iphone\",\n      \"size\": \"76x76\",\n      \"scale\": \"1x\",\n      \"filename\": \"Icon-App-76x76@1x.png\"\n    },\n    {\n      \"idiom\": \"ipad\",\n      \"size\": \"20x20\",\n      \"scale\": \"1x\",\n      \"filename\": \"Icon-App-20x20@1x.png\"\n    },\n    {\n      \"idiom\": \"ipad\",\n      \"size\": \"20x20\",\n      \"scale\": \"2x\",\n      \"filename\": \"Icon-App-20x20@2x.png\"\n    },\n    {\n      \"idiom\": \"ipad\",\n      \"size\": \"29x29\",\n      \"scale\": \"1x\",\n      \"filename\": \"Icon-App-29x29@1x.png\"\n    },\n    {\n      \"idiom\": \"ipad\",\n      \"size\": \"29x29\",\n      \"scale\": \"2x\",\n      \"filename\": \"Icon-App-29x29@2x.png\"\n    },\n    {\n      \"idiom\": \"ipad\",\n      \"size\": \"40x40\",\n      \"scale\": \"1x\",\n      \"filename\": \"Icon-App-40x40@1x.png\"\n    },\n    {\n      \"idiom\": \"ipad\",\n      \"size\": \"40x40\",\n      \"scale\": \"2x\",\n      \"filename\": \"Icon-App-40x40@2x.png\"\n    },\n    {\n      \"size\": \"50x50\",\n      \"idiom\": \"ipad\",\n      \"filename\": \"Icon-Small-50x50@1x.png\",\n      \"scale\": \"1x\"\n    },\n    {\n      \"size\": \"50x50\",\n      \"idiom\": \"ipad\",\n      \"filename\": \"Icon-Small-50x50@2x.png\",\n      \"scale\": \"2x\"\n    },\n    {\n      \"idiom\": \"ipad\",\n      \"size\": \"72x72\",\n      \"scale\": \"1x\",\n      \"filename\": \"Icon-App-72x72@1x.png\"\n    },\n    {\n      \"idiom\": \"ipad\",\n      \"size\": \"72x72\",\n      \"scale\": \"2x\",\n      \"filename\": \"Icon-App-72x72@2x.png\"\n    },\n    {\n      \"idiom\": \"ipad\",\n      \"size\": \"76x76\",\n      \"scale\": \"1x\",\n      \"filename\": \"Icon-App-76x76@1x.png\"\n    },\n    {\n      \"idiom\": \"ipad\",\n      \"size\": \"76x76\",\n      \"scale\": \"2x\",\n      \"filename\": \"Icon-App-76x76@2x.png\"\n    },\n    {\n      \"idiom\": \"ipad\",\n      \"size\": \"76x76\",\n      \"scale\": \"3x\",\n      \"filename\": \"Icon-App-76x76@3x.png\"\n    },\n    {\n      \"idiom\": \"ipad\",\n      \"size\": \"83.5x83.5\",\n      \"scale\": \"2x\",\n      \"filename\": \"Icon-App-83.5x83.5@2x.png\"\n    }\n  ],\n  \"info\": {\n    \"version\": 1,\n    \"author\": \"makeappicon\"\n  }\n}\n"
  },
  {
    "path": "assets/media/mobile_icons/ios/README.md",
    "content": "## iTunesArtwork & iTunesArtwork@2x (App Icon) file extension:\n\nPNG extension is prepended to these two files -\n\nWhile Apple suggested to omit the extension for these files,\nthe '.png' extension is actually required for iTunesConnect submission.\n\nThis is done for you so you don't have to.\n\nHowever, for Ad_hoc or Enterprise distribution, the extension should be removed\nfrom the files before adding to XCode to avoid error.\n\nrefs: https://developer.apple.com/library/ios/qa/qa1686/_index.html\n\n## iTunesArtwork & iTunesArtwork@2x (App Icon) transparency handling:\n\nAs images with alpha channels or transparencies cannot be set as an application's icon on\niTunesConnect, all transparent pixels in your images will be converted into\nsolid blacks.\n\nTo achieve the best result, you're advised to adjust the transparency settings\nin your source files before converting them with makeAppIcon.\n\nrefs: https://developer.apple.com/library/ios/documentation/UserExperience/Conceptual/MobileHIG/AppIcons.html\n"
  },
  {
    "path": "assets/media/mobile_icons/watchkit/AppIcon.appiconset/Contents.json",
    "content": "{\n  \"images\": [\n    {\n      \"size\": \"24x24\",\n      \"idiom\": \"watch\",\n      \"scale\": \"2x\",\n      \"filename\": \"Icon-24@2x.png\",\n      \"role\": \"notificationCenter\",\n      \"subtype\": \"38mm\"\n    },\n    {\n      \"size\": \"27.5x27.5\",\n      \"idiom\": \"watch\",\n      \"scale\": \"2x\",\n      \"filename\": \"Icon-27.5@2x.png\",\n      \"role\": \"notificationCenter\",\n      \"subtype\": \"42mm\"\n    },\n    {\n      \"size\": \"29x29\",\n      \"idiom\": \"watch\",\n      \"scale\": \"2x\",\n      \"filename\": \"Icon-29@2x.png\",\n      \"role\": \"companionSettings\"\n    },\n    {\n      \"size\": \"29x29\",\n      \"idiom\": \"watch\",\n      \"scale\": \"3x\",\n      \"filename\": \"Icon-29@3x.png\",\n      \"role\": \"companionSettings\"\n    },\n    {\n      \"size\": \"40x40\",\n      \"idiom\": \"watch\",\n      \"scale\": \"2x\",\n      \"filename\": \"Icon-40@2x.png\",\n      \"role\": \"appLauncher\",\n      \"subtype\": \"38mm\"\n    },\n    {\n      \"size\": \"44x44\",\n      \"idiom\": \"watch\",\n      \"scale\": \"2x\",\n      \"filename\": \"Icon-44@2x.png\",\n      \"role\": \"longLook\",\n      \"subtype\": \"42mm\"\n    },\n    {\n      \"size\": \"86x86\",\n      \"idiom\": \"watch\",\n      \"scale\": \"2x\",\n      \"filename\": \"Icon-86@2x.png\",\n      \"role\": \"quickLook\",\n      \"subtype\": \"38mm\"\n    },\n    {\n      \"size\": \"98x98\",\n      \"idiom\": \"watch\",\n      \"scale\": \"2x\",\n      \"filename\": \"Icon-98@2x.png\",\n      \"role\": \"quickLook\",\n      \"subtype\": \"42mm\"\n    }\n  ],\n  \"info\": {\n    \"version\": 1,\n    \"author\": \"makeappicon\"\n  }\n}\n"
  },
  {
    "path": "assets/theme/styles.css",
    "content": "/*\n * ============================================================================\n * Conduit Minimal CSS v4\n * Only includes classes actually used in this codebase\n * ============================================================================\n *\n * USED CLASSES:\n * Layout: container, row, col-xs-12, col-md-{3,6,8,9,10,12}, offset-md-{1,2,3}\n * Nav: navbar, navbar-{light,brand,nav}, nav, nav-{pills,item,link}, outline-active\n * Buttons: btn, btn-{sm,lg,primary,secondary}, btn-outline-{primary,secondary,danger}\n * Forms: form-group, form-control, form-control-lg\n * Cards: card, card-{block,footer,text}, comment-form\n * Pagination: pagination, page-item, page-link\n * Tags: tag-list, tag-default, tag-pill, tag-outline\n * Pages: home-page, auth-page, settings-page, editor-page, profile-page, article-page\n * Article: article-preview, article-meta, article-content, article-actions, articles-toggle\n * Comments: comment-author, comment-author-img, mod-options, date-posted\n * User: user-info, user-img, user-pic\n * Misc: banner, logo-font, sidebar, feed-toggle, error-messages, counter,\n *       preview-link, author, date, info, attribution, active, disabled,\n *       pull-xs-right, text-xs-center, empty-feed-message\n *\n * NOTE: auth-page, settings-page, and empty-feed-message are semantic hooks\n * used in templates but require no dedicated styles.\n *\n * ============================================================================\n */\n\n:root {\n  --brand: #33aa44;\n  --brand-hover: #2b8e3a;\n  --brand-active: #237630;\n  --brand-dark: #237630;\n  --brand-light: #7dd88b;\n\n  --danger: #b85c5c;\n  --danger-light: #d7a3a3;\n\n  --secondary: #ccc;\n  --secondary-hover: #b3b3b3;\n  --secondary-border: #adadad;\n\n  --text: #373a3c;\n  --text-muted: #999;\n  --text-light: #bbb;\n  --text-lighter: #aaa;\n  --text-disabled: #818a91;\n\n  --border: #eee;\n  --bg-light: #f3f3f3;\n  --input-focus: var(--brand);\n\n  --content-width: 720px;\n  --content-breakpoint: 1080px;\n}\n\n/* ============================================================================\n * CSS RESET & BASE\n * ============================================================================ */\n\n*,\n*::before,\n*::after {\n  box-sizing: border-box;\n  margin: 0;\n  padding: 0;\n}\n\nhtml {\n  font-size: 16px;\n  -webkit-tap-highlight-color: transparent;\n}\n\nbody {\n  display: flex;\n  flex-direction: column;\n  min-height: 100vh;\n  font-family: 'Source Sans Pro', sans-serif;\n  font-size: 1rem;\n  line-height: 1.5;\n  color: var(--text);\n  background-color: #fff;\n}\n\n/* Main content area grows to push footer down */\napp-root {\n  display: flex;\n  flex-direction: column;\n  flex: 1;\n}\n\napp-root > *:not(app-layout-footer) {\n  flex-shrink: 0;\n}\n\napp-root > app-layout-footer,\napp-root > footer {\n  margin-top: auto;\n}\n\nol,\nul {\n  list-style: none;\n}\n\nhr {\n  margin-top: 1rem;\n  margin-bottom: 1rem;\n  border: 0;\n  border-top: 1px solid #eee;\n}\n\na {\n  color: var(--brand);\n  text-decoration: none;\n  background-color: transparent;\n  transition: color 0.15s ease;\n}\n\na:focus,\na:hover {\n  color: var(--brand-dark);\n  text-decoration: underline;\n}\n\na:focus {\n  outline: thin dotted;\n  outline: 5px auto -webkit-focus-ring-color;\n  outline-offset: -2px;\n}\n\na:not([href]) {\n  color: inherit;\n  text-decoration: none;\n}\n\na:not([href]):focus,\na:not([href]):hover {\n  color: inherit;\n  text-decoration: none;\n}\n\na:not([href]):focus {\n  outline: none;\n}\n\nimg {\n  vertical-align: middle;\n  border: 0;\n}\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n  margin-bottom: 0.5rem;\n  font-weight: 500;\n  line-height: 1.1;\n}\n\nh1 {\n  font-size: 2.5rem;\n}\nh2 {\n  font-size: 2rem;\n}\nh3 {\n  font-size: 1.75rem;\n}\nh4 {\n  font-size: 1.5rem;\n}\nh5 {\n  font-size: 1.25rem;\n}\nh6 {\n  font-size: 1rem;\n}\n\np {\n  margin-bottom: 1rem;\n}\n\n.logo-font {\n  font-family: 'Lora', serif;\n}\n\n.navbar-logo {\n  height: 1.25rem;\n  vertical-align: middle;\n}\n\n.banner-logo {\n  height: 3rem;\n}\n\n.footer-logo {\n  height: 1rem;\n  display: block;\n}\n\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n  font: inherit;\n  color: inherit;\n  margin: 0;\n}\n\nbutton {\n  overflow: visible;\n}\n\nbutton,\nselect {\n  text-transform: none;\n}\n\nbutton,\nhtml input[type='button'],\ninput[type='reset'],\ninput[type='submit'] {\n  -webkit-appearance: button;\n  cursor: pointer;\n}\n\nbutton[disabled],\nhtml input[disabled] {\n  cursor: default;\n}\n\nbutton::-moz-focus-inner,\ninput::-moz-focus-inner {\n  border: 0;\n  padding: 0;\n}\n\ninput {\n  line-height: normal;\n}\n\ntextarea {\n  overflow: auto;\n  resize: vertical;\n}\n\ninput,\nbutton,\nselect,\ntextarea {\n  line-height: inherit;\n  border-radius: 0;\n}\n\na,\narea,\nbutton,\n[role='button'],\ninput,\nlabel,\nselect,\nsummary,\ntextarea {\n  touch-action: manipulation;\n}\n\nbutton:focus {\n  outline: 1px dotted;\n  outline: 5px auto -webkit-focus-ring-color;\n}\n\nfieldset {\n  min-width: 0;\n  padding: 0;\n  margin: 0;\n  border: 0;\n}\n\n/* ============================================================================\n * LAYOUT: CONTAINER & GRID\n * ============================================================================ */\n\n.container {\n  margin-left: auto;\n  margin-right: auto;\n  padding-left: 15px;\n  padding-right: 15px;\n}\n\n@media (min-width: 544px) {\n  .container {\n    max-width: 576px;\n  }\n}\n\n@media (min-width: 768px) {\n  .container {\n    max-width: 720px;\n  }\n}\n\n@media (min-width: 992px) {\n  .container {\n    max-width: 940px;\n  }\n}\n\n@media (min-width: 1200px) {\n  .container {\n    max-width: 1140px;\n  }\n}\n\n.row {\n  display: flex;\n  flex-wrap: wrap;\n  margin-left: -15px;\n  margin-right: -15px;\n}\n\n.col-xs-12 {\n  position: relative;\n  min-height: 1px;\n  padding-right: 15px;\n  padding-left: 15px;\n  flex: 0 0 100%;\n  max-width: 100%;\n}\n\n@media (min-width: 768px) {\n  .col-md-3 {\n    position: relative;\n    min-height: 1px;\n    padding-right: 15px;\n    padding-left: 15px;\n    flex: 0 0 25%;\n    max-width: 25%;\n  }\n  .col-md-6 {\n    position: relative;\n    min-height: 1px;\n    padding-right: 15px;\n    padding-left: 15px;\n    flex: 0 0 50%;\n    max-width: 50%;\n  }\n  .col-md-8 {\n    position: relative;\n    min-height: 1px;\n    padding-right: 15px;\n    padding-left: 15px;\n    flex: 0 0 66.66667%;\n    max-width: 66.66667%;\n  }\n  .col-md-9 {\n    position: relative;\n    min-height: 1px;\n    padding-right: 15px;\n    padding-left: 15px;\n    flex: 0 0 75%;\n    max-width: 75%;\n  }\n  .col-md-10 {\n    position: relative;\n    min-height: 1px;\n    padding-right: 15px;\n    padding-left: 15px;\n    flex: 0 0 83.33333%;\n    max-width: 83.33333%;\n  }\n  .col-md-12 {\n    position: relative;\n    min-height: 1px;\n    padding-right: 15px;\n    padding-left: 15px;\n    flex: 0 0 100%;\n    max-width: 100%;\n  }\n  .offset-md-1 {\n    margin-left: 8.33333%;\n  }\n  .offset-md-2 {\n    margin-left: 16.66667%;\n  }\n  .offset-md-3 {\n    margin-left: 25%;\n  }\n}\n\n/* ============================================================================\n * FORMS\n * ============================================================================ */\n\n.form-control {\n  display: block;\n  width: 100%;\n  padding: 0.5rem 0.75rem;\n  font-size: 1rem;\n  line-height: 1.25;\n  color: #55595c;\n  background-color: #fff;\n  background-image: none;\n  background-clip: padding-box;\n  border: 1px solid #eee;\n  border-radius: 0.5rem;\n  transition: border-color 0.15s ease;\n}\n\n.form-control:focus {\n  border-color: var(--input-focus);\n  outline: none;\n}\n\n.form-control::placeholder {\n  color: var(--text-muted);\n  opacity: 1;\n}\n\n.form-control:disabled,\n.form-control[readonly] {\n  background-color: #eceeef;\n  opacity: 1;\n}\n\n.form-control:disabled {\n  cursor: not-allowed;\n}\n\n.form-control-lg {\n  padding: 0.75rem 1.5rem;\n  font-size: 1.25rem;\n  border-radius: 0.625rem;\n}\n\n.form-group {\n  margin-bottom: 1rem;\n}\n\n/* ============================================================================\n * BUTTONS\n * ============================================================================ */\n\n.btn {\n  display: inline-block;\n  font-weight: normal;\n  line-height: 1.25;\n  text-align: center;\n  white-space: nowrap;\n  vertical-align: middle;\n  cursor: pointer;\n  user-select: none;\n  border: 1px solid transparent;\n  padding: 0.5rem 1rem;\n  font-size: 1rem;\n  border-radius: 0.5rem;\n  transition:\n    color 0.15s ease,\n    background-color 0.15s ease,\n    border-color 0.15s ease,\n    box-shadow 0.15s ease;\n}\n\n.btn:focus,\n.btn.focus,\n.btn:active:focus,\n.btn:active.focus,\n.btn.active:focus,\n.btn.active.focus {\n  outline: thin dotted;\n  outline: 5px auto -webkit-focus-ring-color;\n  outline-offset: -2px;\n}\n\n.btn:focus,\n.btn:hover {\n  text-decoration: none;\n}\n\n.btn.focus {\n  text-decoration: none;\n}\n\n.btn:active,\n.btn.active {\n  background-image: none;\n  outline: 0;\n}\n\n.btn.disabled,\n.btn:disabled {\n  cursor: not-allowed;\n  opacity: 0.65;\n}\n\na.btn.disabled,\nfieldset[disabled] a.btn {\n  pointer-events: none;\n}\n\n/* btn-primary */\n.btn-primary {\n  color: #fff;\n  background-color: var(--brand);\n  border-color: var(--brand);\n}\n\n.btn-primary:hover {\n  color: #fff;\n  background-color: var(--brand-hover);\n  border-color: var(--brand-hover);\n}\n\n.btn-primary:focus,\n.btn-primary.focus {\n  color: #fff;\n  background-color: var(--brand-hover);\n  border-color: var(--brand-hover);\n}\n\n.btn-primary:active,\n.btn-primary.active {\n  color: #fff;\n  background-color: var(--brand-hover);\n  border-color: var(--brand-hover);\n  background-image: none;\n}\n\n.btn-primary:active:hover,\n.btn-primary:active:focus,\n.btn-primary:active.focus,\n.btn-primary.active:hover,\n.btn-primary.active:focus,\n.btn-primary.active.focus {\n  color: #fff;\n  background-color: var(--brand-active);\n  border-color: var(--brand-active);\n}\n\n.btn-primary.disabled:focus,\n.btn-primary.disabled.focus,\n.btn-primary:disabled:focus,\n.btn-primary:disabled.focus {\n  background-color: var(--brand);\n  border-color: var(--brand);\n}\n\n.btn-primary.disabled:hover,\n.btn-primary:disabled:hover {\n  background-color: var(--brand);\n  border-color: var(--brand);\n}\n\n/* btn-secondary */\n.btn-secondary {\n  color: #fff;\n  background-color: var(--secondary);\n  border-color: var(--secondary);\n}\n\n.btn-secondary:hover {\n  color: #fff;\n  background-color: var(--secondary-hover);\n  border-color: var(--secondary-border);\n}\n\n.btn-secondary:focus,\n.btn-secondary.focus {\n  color: #fff;\n  background-color: var(--secondary-hover);\n  border-color: var(--secondary-border);\n}\n\n.btn-secondary:active,\n.btn-secondary.active {\n  color: #fff;\n  background-color: var(--secondary-hover);\n  border-color: var(--secondary-border);\n  background-image: none;\n}\n\n.btn-secondary.disabled:focus,\n.btn-secondary.disabled.focus,\n.btn-secondary:disabled:focus,\n.btn-secondary:disabled.focus {\n  background-color: var(--secondary);\n  border-color: var(--secondary);\n}\n\n.btn-secondary.disabled:hover,\n.btn-secondary:disabled:hover {\n  background-color: var(--secondary);\n  border-color: var(--secondary);\n}\n\n/* btn-outline-primary */\n.btn-outline-primary {\n  color: var(--brand);\n  background-image: none;\n  background-color: transparent;\n  border-color: var(--brand);\n}\n\n.btn-outline-primary:hover {\n  color: #fff;\n  background-color: var(--brand);\n  border-color: var(--brand);\n}\n\n.btn-outline-primary:focus,\n.btn-outline-primary.focus {\n  color: #fff;\n  background-color: var(--brand);\n  border-color: var(--brand);\n}\n\n.btn-outline-primary:active,\n.btn-outline-primary.active {\n  color: #fff;\n  background-color: var(--brand);\n  border-color: var(--brand);\n}\n\n.btn-outline-primary.disabled:focus,\n.btn-outline-primary.disabled.focus,\n.btn-outline-primary:disabled:focus,\n.btn-outline-primary:disabled.focus {\n  border-color: var(--brand-light);\n}\n\n.btn-outline-primary.disabled:hover,\n.btn-outline-primary:disabled:hover {\n  border-color: var(--brand-light);\n}\n\n/* btn-outline-secondary */\n.btn-outline-secondary {\n  color: var(--secondary);\n  background-image: none;\n  background-color: transparent;\n  border-color: var(--secondary);\n}\n\n.btn-outline-secondary:hover {\n  color: #fff;\n  background-color: var(--secondary);\n  border-color: var(--secondary);\n}\n\n.btn-outline-secondary:focus,\n.btn-outline-secondary.focus {\n  color: #fff;\n  background-color: var(--secondary);\n  border-color: var(--secondary);\n}\n\n.btn-outline-secondary:active,\n.btn-outline-secondary.active {\n  color: #fff;\n  background-color: var(--secondary);\n  border-color: var(--secondary);\n}\n\n.btn-outline-secondary.disabled:focus,\n.btn-outline-secondary.disabled.focus,\n.btn-outline-secondary:disabled:focus,\n.btn-outline-secondary:disabled.focus {\n  border-color: white;\n}\n\n.btn-outline-secondary.disabled:hover,\n.btn-outline-secondary:disabled:hover {\n  border-color: white;\n}\n\n/* btn-outline-danger */\n.btn-outline-danger {\n  color: var(--danger);\n  background-image: none;\n  background-color: transparent;\n  border-color: var(--danger);\n}\n\n.btn-outline-danger:hover {\n  color: #fff;\n  background-color: var(--danger);\n  border-color: var(--danger);\n}\n\n.btn-outline-danger:focus,\n.btn-outline-danger.focus {\n  color: #fff;\n  background-color: var(--danger);\n  border-color: var(--danger);\n}\n\n.btn-outline-danger:active,\n.btn-outline-danger.active {\n  color: #fff;\n  background-color: var(--danger);\n  border-color: var(--danger);\n}\n\n.btn-outline-danger.disabled:focus,\n.btn-outline-danger.disabled.focus,\n.btn-outline-danger:disabled:focus,\n.btn-outline-danger:disabled.focus {\n  border-color: var(--danger-light);\n}\n\n.btn-outline-danger.disabled:hover,\n.btn-outline-danger:disabled:hover {\n  border-color: var(--danger-light);\n}\n\n/* Button sizes */\n.btn-lg {\n  padding: 0.75rem 1.5rem;\n  font-size: 1.25rem;\n  border-radius: 0.625rem;\n}\n\n.btn-sm {\n  padding: 0.25rem 0.5rem;\n  font-size: 0.875rem;\n  border-radius: 0.375rem;\n}\n\n/* ============================================================================\n * NAVIGATION\n * ============================================================================ */\n\n.nav {\n  padding-left: 0;\n  margin-bottom: 0;\n  list-style: none;\n}\n\n.nav-link {\n  display: inline-block;\n  transition:\n    color 0.15s ease,\n    background-color 0.15s ease;\n}\n\n.nav-link:focus,\n.nav-link:hover {\n  text-decoration: none;\n}\n\n.nav-link.disabled,\n.nav-link.disabled:focus,\n.nav-link.disabled:hover {\n  color: var(--text-disabled);\n  cursor: not-allowed;\n  background-color: transparent;\n}\n\n.nav-pills {\n  display: flex;\n  flex-wrap: wrap;\n}\n\n.nav-pills .nav-item + .nav-item {\n  margin-left: 0.2rem;\n}\n\n.nav-pills .nav-link {\n  display: block;\n  padding: 0.5em 1em;\n  border-radius: 0.5rem;\n}\n\n.nav-pills .nav-link.active,\n.nav-pills .nav-link.active:focus,\n.nav-pills .nav-link.active:hover {\n  color: #fff;\n  cursor: default;\n  background-color: var(--brand);\n}\n\n/* Navbar */\n.navbar {\n  position: relative;\n  padding: 0.5rem 1rem;\n}\n\n.navbar > .container {\n  display: flex;\n  align-items: center;\n}\n\n.navbar-brand {\n  padding-top: 0;\n  padding-bottom: 0.25rem;\n  margin-right: 2rem;\n  font-family: 'Lora', serif;\n  font-size: 1.5rem;\n  color: #222;\n}\n\n.navbar-brand:focus,\n.navbar-brand:hover {\n  text-decoration: none;\n  color: #222;\n}\n\n.navbar-nav {\n  display: flex;\n  align-items: center;\n  margin-left: auto;\n}\n\n.navbar-nav .nav-link {\n  display: block;\n  padding-top: 0.425rem;\n  padding-bottom: 0.425rem;\n}\n\n.navbar-nav .nav-link + .nav-link {\n  margin-left: 1rem;\n}\n\n.navbar-nav .nav-item + .nav-item {\n  margin-left: 1rem;\n}\n\n.navbar-light .navbar-brand,\n.navbar-light .navbar-brand:focus,\n.navbar-light .navbar-brand:hover {\n  color: #222;\n}\n\n.navbar-light .navbar-nav .nav-link {\n  color: rgba(0, 0, 0, 0.3);\n}\n\n.navbar-light .navbar-nav .nav-link:focus,\n.navbar-light .navbar-nav .nav-link:hover {\n  color: rgba(0, 0, 0, 0.6);\n}\n\n.navbar-light .navbar-nav .active > .nav-link,\n.navbar-light .navbar-nav .active > .nav-link:focus,\n.navbar-light .navbar-nav .active > .nav-link:hover,\n.navbar-light .navbar-nav .nav-link.active,\n.navbar-light .navbar-nav .nav-link.active:focus,\n.navbar-light .navbar-nav .nav-link.active:hover {\n  color: rgba(0, 0, 0, 0.8);\n}\n\n.nav-link .user-pic {\n  height: 26px;\n  border-radius: 50px;\n  float: left;\n  margin-right: 5px;\n}\n\n.nav-link:hover {\n  text-decoration: none;\n}\n\n.nav-signup {\n  color: #fff !important;\n  background: var(--brand);\n  border: 1px solid var(--brand);\n  border-radius: 99em;\n  padding: 5px 12px !important;\n  font-size: 0.8125rem;\n  line-height: 1.25rem;\n  transition:\n    background 200ms ease,\n    border-color 200ms ease;\n}\n\n.nav-signup:hover,\n.nav-signup:focus {\n  color: #fff !important;\n  background: var(--brand-hover);\n  border-color: var(--brand-hover);\n}\n\n.nav-pills.outline-active .nav-link {\n  border-radius: 0;\n  border: none;\n  border-bottom: 2px solid transparent;\n  background: transparent;\n  color: var(--text-lighter);\n}\n\n.nav-pills.outline-active .nav-link:hover {\n  color: #555;\n}\n\n.nav-pills.outline-active .nav-link.active {\n  background: #fff;\n  border-bottom: 2px solid var(--brand);\n  color: var(--brand);\n}\n\n/* ============================================================================\n * CARDS\n * ============================================================================ */\n\n.card {\n  position: relative;\n  display: block;\n  margin-bottom: 0.75rem;\n  background-color: #fff;\n  border-radius: 0.75rem;\n  border: 1px solid #eee;\n  box-shadow:\n    0 1px 3px rgba(0, 0, 0, 0.08),\n    0 1px 2px rgba(0, 0, 0, 0.06);\n  transition: box-shadow 0.2s ease;\n}\n\n.card-block {\n  padding: 1.25rem;\n}\n\n.card-block::after {\n  content: '';\n  display: table;\n  clear: both;\n}\n\n.card-text:last-child {\n  margin-bottom: 0;\n}\n\n.card-footer {\n  padding: 0.75rem 1.25rem;\n  background-color: #f5f5f5;\n  border-top: 1px solid #eee;\n}\n\n.card-footer::after {\n  content: '';\n  display: table;\n  clear: both;\n}\n\n.card-footer:last-child {\n  border-radius: 0 0 0.75rem 0.75rem;\n}\n\n/* ============================================================================\n * PAGINATION\n * ============================================================================ */\n\n.pagination {\n  display: inline-block;\n  padding-left: 0;\n  margin-top: 1rem;\n  margin-bottom: 1rem;\n  border-radius: 0.5rem;\n}\n\n.page-item {\n  display: inline;\n}\n\n.page-item:first-child .page-link {\n  margin-left: 0;\n  border-bottom-left-radius: 0.5rem;\n  border-top-left-radius: 0.5rem;\n}\n\n.page-item:last-child .page-link {\n  border-bottom-right-radius: 0.5rem;\n  border-top-right-radius: 0.5rem;\n}\n\n.page-item.active .page-link,\n.page-item.active .page-link:focus,\n.page-item.active .page-link:hover {\n  z-index: 2;\n  color: #222;\n  cursor: default;\n  background-color: #eee;\n  border-color: #eee;\n}\n\n.page-item.disabled .page-link,\n.page-item.disabled .page-link:focus,\n.page-item.disabled .page-link:hover {\n  color: var(--text-disabled);\n  pointer-events: none;\n  cursor: not-allowed;\n  background-color: #fff;\n  border-color: var(--border);\n}\n\n.page-link {\n  position: relative;\n  float: left;\n  padding: 0.5rem 0.75rem;\n  margin-left: -1px;\n  color: var(--brand);\n  text-decoration: none;\n  background-color: #fff;\n  border: 1px solid var(--border);\n  transition:\n    color 0.15s ease,\n    background-color 0.15s ease;\n}\n\n.page-link:focus,\n.page-link:hover {\n  color: var(--brand-dark);\n  background-color: #eceeef;\n  border-color: var(--border);\n}\n\n/* ============================================================================\n * TAGS\n * ============================================================================ */\n\n.tag-pill {\n  padding-right: 0.6em;\n  padding-left: 0.6em;\n  border-radius: 10rem;\n}\n\n.tag-default {\n  background-color: transparent;\n  color: #222;\n  border: 1px solid #eee;\n  border-radius: 100px;\n  font-size: 0.8rem;\n  line-height: 1.1rem;\n  padding: 0.35rem 0.75rem;\n  white-space: nowrap;\n  margin-right: 0.5rem;\n  margin-bottom: 0.5rem;\n  display: inline-block;\n  transition: background 300ms ease;\n}\n\n.tag-default:hover {\n  text-decoration: none;\n  background-color: #eee;\n}\n\n.tag-default[href]:focus,\n.tag-default[href]:hover {\n  background-color: #eee;\n}\n\n.tag-default.tag-outline {\n  border: 1px solid var(--border);\n  color: var(--text-lighter);\n  background: none;\n}\n\nul.tag-list {\n  display: inline-block;\n}\n\nul.tag-list li {\n  display: inline-block;\n}\n\n/* ============================================================================\n * UTILITIES\n * ============================================================================ */\n\n.pull-xs-right {\n  float: right !important;\n}\n\n.text-xs-center {\n  text-align: center !important;\n}\n\n/* ============================================================================\n * FOOTER\n * ============================================================================ */\n\nfooter {\n  background: #fff;\n  border-top: 1px solid #eee;\n  margin-top: 42px;\n  padding: 1rem 0;\n  width: 100%;\n}\n\nfooter .logo-font {\n  color: #222;\n}\n\nfooter a {\n  color: #222;\n}\n\nfooter .attribution {\n  margin-left: 10px;\n  font-size: 0.8rem;\n  color: var(--text-light);\n  font-weight: 300;\n}\n\n/* ============================================================================\n * ERROR MESSAGES\n * ============================================================================ */\n\n.error-messages {\n  color: var(--danger);\n  font-weight: bold;\n  padding-left: 2.5rem;\n  margin-bottom: 1rem;\n  list-style: disc;\n}\n\n/* ============================================================================\n * BANNER\n * ============================================================================ */\n\n.banner {\n  color: var(--text);\n  background: #fff;\n  border-top: 1px solid #eee;\n  border-bottom: 1px solid #eee;\n  padding: 2rem;\n  margin-bottom: 2rem;\n}\n\n.banner h1 {\n  margin-bottom: 0;\n}\n\n.container.page {\n  margin-top: 1.5rem;\n}\n\n/* ============================================================================\n * ARTICLE PREVIEW & META\n * ============================================================================ */\n\n.preview-link {\n  color: inherit;\n}\n\n.preview-link:hover {\n  text-decoration: inherit;\n}\n\n.article-meta {\n  display: block;\n  position: relative;\n  font-weight: 300;\n}\n\n.article-meta img {\n  display: inline-block;\n  vertical-align: middle;\n  height: 32px;\n  width: 32px;\n  border-radius: 30px;\n}\n\n.article-meta .btn + .btn,\n.article-meta app-follow-button + app-favorite-button,\n.article-meta app-favorite-button {\n  margin-left: 0.5rem;\n}\n\n.article-meta .info {\n  margin: 0 1.5rem 0 0.5rem;\n  display: inline-block;\n  vertical-align: middle;\n  line-height: 1rem;\n}\n\n.article-meta .info .author {\n  display: block;\n  font-weight: 500;\n}\n\n.article-meta .info .date {\n  color: var(--text-light);\n  font-size: 0.8rem;\n  display: block;\n}\n\n.article-preview {\n  border-top: 1px solid #eee;\n  padding: 1.5rem 0;\n  transition: background-color 0.2s ease;\n}\n\n.article-preview:hover {\n  background-color: rgba(0, 0, 0, 0.01);\n}\n\n.article-preview .article-meta {\n  margin: 0 0 1rem 0;\n}\n\n.article-preview .preview-link h1 {\n  font-weight: 600;\n  font-size: 1.5rem;\n  line-height: 1.1;\n  margin-bottom: 3px;\n}\n\n.article-preview .preview-link p {\n  font-weight: 300;\n  font-size: 1rem;\n  color: var(--text-muted);\n  margin-bottom: 15px;\n  line-height: 1.3rem;\n}\n\n.article-preview .preview-link span {\n  max-width: 30%;\n  font-size: 0.8rem;\n  font-weight: 300;\n  color: var(--text-light);\n  vertical-align: middle;\n}\n\n.article-preview .preview-link ul {\n  float: right;\n  max-width: 50%;\n  vertical-align: top;\n}\n\n.article-preview .preview-link ul li {\n  font-weight: 300;\n  font-size: 0.8rem;\n  padding-top: 0;\n  padding-bottom: 0;\n}\n\n.btn .counter {\n  font-size: 0.8rem;\n}\n\n/* ============================================================================\n * HOME PAGE\n * ============================================================================ */\n\n.home-page .banner p {\n  text-align: center;\n  font-size: 1rem;\n  font-weight: 300;\n  color: var(--text-muted);\n  margin-bottom: 0;\n}\n\n.home-page .banner p a {\n  color: var(--brand);\n  text-decoration: underline;\n}\n\n.home-page .banner h1 {\n  font-weight: 700;\n  text-align: center;\n  font-size: 3.5rem;\n  line-height: 1.1;\n  padding-bottom: 0.5rem;\n}\n\n.home-page .feed-toggle {\n  margin-bottom: -1px;\n}\n\n.home-page .sidebar {\n  margin-left: 10px;\n  padding-left: 20px;\n  background: none;\n  border-left: 1px solid #eee;\n}\n\n.home-page .sidebar p {\n  margin-bottom: 0.2rem;\n}\n\n/* ============================================================================\n * ARTICLE PAGE\n * ============================================================================ */\n\n.article-page .banner {\n  padding: 2rem 0;\n}\n\n.article-page .banner h1 {\n  font-size: 2.8rem;\n  font-weight: 600;\n  color: #222;\n}\n\n.article-page .banner .btn {\n  opacity: 0.8;\n}\n\n.article-page .banner .btn:hover {\n  transition: 0.1s all;\n  opacity: 1;\n}\n\n.article-page .banner .article-meta {\n  margin: 2rem 0 0 0;\n}\n\n.article-page .banner .article-meta .author {\n  color: var(--text);\n}\n\n.article-page .banner > .container,\n.article-page .article-content,\n.article-page .article-actions,\n.article-page .container.page > .row {\n  max-width: var(--content-width);\n  margin-left: auto;\n  margin-right: auto;\n}\n\n.article-page .article-content.row,\n.article-page .container.page > .row {\n  margin-left: auto;\n  margin-right: auto;\n}\n\n.article-page .article-content .col-md-12,\n.article-page .container.page > .row > [class*='col-'] {\n  padding-left: 0;\n  padding-right: 0;\n  flex: 0 0 100%;\n  max-width: 100%;\n  margin-left: 0;\n}\n\nfooter > .container {\n  display: flex;\n  align-items: center;\n  max-width: var(--content-width);\n  margin-left: auto;\n  margin-right: auto;\n}\n\n.article-page .article-content p {\n  font-family: 'Lora', serif;\n  font-size: 1.2rem;\n  line-height: 1.8rem;\n  margin-bottom: 2rem;\n}\n\n.article-page .article-content h1,\n.article-page .article-content h2,\n.article-page .article-content h3,\n.article-page .article-content h4,\n.article-page .article-content h5,\n.article-page .article-content h6 {\n  font-weight: 500;\n  margin: 1.6rem 0 1rem 0;\n}\n\n.article-page .article-content ul,\n.article-page .article-content ol {\n  margin-bottom: 1rem;\n  padding-left: 2.5rem;\n}\n\n.article-page .article-content ul ul,\n.article-page .article-content ul ol,\n.article-page .article-content ol ul,\n.article-page .article-content ol ol {\n  margin-bottom: 0;\n  padding-left: 2.5rem;\n}\n\n.article-page .article-content ul {\n  list-style: disc;\n}\n\n.article-page .article-content ul ul {\n  list-style: circle;\n}\n\n.article-page .article-content ul ul ul {\n  list-style: square;\n}\n\n.article-page .article-content ol {\n  list-style: decimal;\n}\n\n.article-page .article-content ol ol {\n  list-style: lower-alpha;\n}\n\n.article-page .article-content ol ol ol {\n  list-style: lower-roman;\n}\n\n.article-page .article-actions {\n  text-align: center;\n  margin-top: 1.5rem;\n  margin-bottom: 3rem;\n}\n\n.article-page .article-actions .article-meta .info {\n  text-align: left;\n}\n\n.article-page .comment-form .card-block {\n  padding: 0;\n}\n\n.article-page .comment-form .card-block textarea {\n  border: 0;\n  padding: 1.25rem;\n}\n\n.article-page .comment-form .card-footer .btn {\n  font-weight: 700;\n  float: right;\n}\n\n.article-page .comment-form .card-footer .comment-author-img {\n  height: 30px;\n  width: 30px;\n}\n\n.article-page .card {\n  border: 1px solid #eee;\n  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);\n}\n\n.article-page .card .card-footer {\n  border-top: 1px solid #eee;\n  font-size: 0.8rem;\n  font-weight: 300;\n}\n\n.article-page .card .comment-author-img {\n  display: inline-block;\n  vertical-align: middle;\n  height: 20px;\n  width: 20px;\n  border-radius: 30px;\n}\n\n.article-page .card .comment-author {\n  display: inline-block;\n  vertical-align: middle;\n}\n\n.article-page .card .date-posted {\n  display: inline-block;\n  vertical-align: middle;\n  margin-left: 5px;\n  color: var(--text-light);\n}\n\n.article-page .card .mod-options {\n  float: right;\n  color: #333;\n  font-size: 1rem;\n}\n\n.article-page .card .mod-options i {\n  margin-left: 5px;\n  opacity: 0.6;\n  cursor: pointer;\n}\n\n.article-page .card .mod-options i:hover {\n  opacity: 1;\n}\n\n/* ============================================================================\n * PROFILE PAGE\n * ============================================================================ */\n\n.profile-page .user-info {\n  text-align: center;\n  background: #fff;\n  border-top: 1px solid #eee;\n  border-bottom: 1px solid #eee;\n  padding: 2rem 0 1rem 0;\n}\n\n.profile-page .user-info .user-img {\n  width: 100px;\n  height: 100px;\n  border-radius: 100px;\n  margin-bottom: 1rem;\n}\n\n.profile-page .user-info h4 {\n  font-weight: 700;\n}\n\n.profile-page .user-info p {\n  margin: 0 auto 0.5rem auto;\n  color: var(--text-lighter);\n  max-width: 450px;\n  font-weight: 300;\n}\n\n.profile-page .user-info .action-btn {\n  float: right;\n  color: var(--text-muted);\n  border: 1px solid var(--text-muted);\n}\n\n.profile-page .articles-toggle {\n  margin: 1.5rem 0 -1px 0;\n}\n\n/* ============================================================================\n * EDITOR PAGE\n * ============================================================================ */\n\n.editor-page .tag-list i {\n  font-size: 0.6rem;\n  margin-right: 5px;\n  cursor: pointer;\n}\n"
  },
  {
    "path": "docs/.gitignore",
    "content": "# build output\ndist/\n# generated types\n.astro/\n\n# dependencies\nnode_modules/\n\n# logs\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\n\n\n# environment variables\n.env\n.env.production\n\n# macOS-specific files\n.DS_Store\n"
  },
  {
    "path": "docs/.vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\"astro-build.astro-vscode\"],\n  \"unwantedRecommendations\": []\n}\n"
  },
  {
    "path": "docs/.vscode/launch.json",
    "content": "{\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"command\": \"./node_modules/.bin/astro dev\",\n      \"name\": \"Development server\",\n      \"request\": \"launch\",\n      \"type\": \"node-terminal\"\n    }\n  ]\n}\n"
  },
  {
    "path": "docs/README.md",
    "content": "# Starlight Starter Kit: Basics\n\n[![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build)\n\n```\nnpm create astro@latest -- --template starlight\n```\n\n[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/starlight/tree/main/examples/basics)\n[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/starlight/tree/main/examples/basics)\n[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/withastro/starlight&create_from_path=examples/basics)\n[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwithastro%2Fstarlight%2Ftree%2Fmain%2Fexamples%2Fbasics&project-name=my-starlight-docs&repository-name=my-starlight-docs)\n\n> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!\n\n## 🚀 Project Structure\n\nInside of your Astro + Starlight project, you'll see the following folders and files:\n\n```\n.\n├── public/\n├── src/\n│   ├── assets/\n│   ├── content/\n│   │   ├── docs/\n│   │   └── config.ts\n│   └── env.d.ts\n├── astro.config.mjs\n├── package.json\n└── tsconfig.json\n```\n\nStarlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name.\n\nImages can be added to `src/assets/` and embedded in Markdown with a relative link.\n\nStatic assets, like favicons, can be placed in the `public/` directory.\n\n## 🧞 Commands\n\nAll commands are run from the root of the project, from a terminal:\n\n| Command                   | Action                                           |\n| :------------------------ | :----------------------------------------------- |\n| `npm install`             | Installs dependencies                            |\n| `npm run dev`             | Starts local dev server at `localhost:4321`      |\n| `npm run build`           | Build your production site to `./dist/`          |\n| `npm run preview`         | Preview your build locally, before deploying     |\n| `npm run astro ...`       | Run CLI commands like `astro add`, `astro check` |\n| `npm run astro -- --help` | Get help using the Astro CLI                     |\n\n## 👀 Want to learn more?\n\nCheck out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).\n"
  },
  {
    "path": "docs/astro.config.mjs",
    "content": "import {defineConfig} from 'astro/config';\nimport starlight from '@astrojs/starlight';\nimport tailwindcss from \"@tailwindcss/vite\";\n\n/**\n * A Vite plugin that removes `.md` extensions from URLs during the build process.\n *\n * This plugin is useful when working with Markdown files in a static site generator\n * like Astro or Vite. It allows URLs to be served without the `.md` extension in\n * the final build, making the URLs cleaner (e.g., `/docs/page` instead of `/docs/page.md`).\n *\n * ## Example Usage\n * ```js\n * import { defineConfig } from 'astro/config';\n *\n * export default defineConfig({\n *   vite: {\n *     plugins: [removeMdExtension()],\n *   },\n * });\n * ```\n *\n * @typedef {object} VitePlugin\n * @property {string} name - The name of the plugin, in this case, `remove-md-extension`.\n * @property {string} enforce - Specifies the plugin's enforcement stage, set to `pre`\n * to ensure that this plugin runs before other transformations during the build.\n * @property {function} transform - The function that processes each file, removing\n * `.md` extensions from the file content. It is called on every file during the build process.\n *\n * @returns {VitePlugin} A Vite-compatible plugin object that contains the `name`,\n * `enforce`, and `transform` properties, implementing the plugin functionality.\n *\n * ## Vite Plugin Object Structure\n * - `name`: The name of the plugin (`remove-md-extension`).\n * - `enforce`: Ensures the plugin runs early (`pre` stage).\n * - `transform(code: string, id: string): string`: The function that processes the content of `.md` files.\n *\n * @param {string} code - The content of the file being processed (e.g., the raw Markdown).\n * @param {string} id - The file identifier (usually the file path), used to check if the file ends in `.md`.\n * @returns {string} The modified file content, with any `.md` extensions in URLs removed.\n */\nfunction removeMdExtension() {\n    return {\n        name: 'remove-md-extension',\n        enforce: 'pre',\n        transform(code, id) {\n            if (id.endsWith('.md')) {\n                return code.replace(/\\.md/g, '');\n            }\n            return code;\n        },\n    };\n};\n\n// https://astro.build/config\nexport default defineConfig({\n    integrations: [starlight({\n        title: 'RealWorld',\n        social: [\n            {\n                icon: 'github',\n                label: 'GitHub',\n                href: 'https://github.com/realworld-apps/realworld'\n            }\n        ],\n        customCss: [\n            './src/tailwind.css',\n        ],\n        sidebar: [\n            {\n                label: 'Implementation creation',\n                items: [\n                    {\n                        label: 'Introduction',\n                        slug: 'implementation-creation/introduction',\n                    },\n                    {\n                        label: 'Features',\n                        slug: 'implementation-creation/features',\n                    },\n                    {\n                        label: 'Expectations',\n                        slug: 'implementation-creation/expectations',\n                    }\n\n                ]\n            },\n            {\n                label: 'Specifications',\n                items: [\n                    {\n                        label: 'Frontend specifications',\n                        items: [\n                            {\n                                label: 'Templates',\n                                slug: 'specifications/frontend/templates',\n                            },\n                            {\n                              label: 'Styles',\n                                slug: 'specifications/frontend/styles',\n                            },\n                            {\n                                label: 'Routing',\n                                slug: 'specifications/frontend/routing',\n                            },\n                            {\n                                label: 'API',\n                                slug: 'specifications/frontend/api',\n                            },\n                            {\n                                label: 'Tests',\n                                slug: 'specifications/frontend/tests',\n                            }\n                        ]\n                    },\n                    {\n                        label: 'Backend specifications',\n                        items: [\n                            {\n                                label: 'Introduction',\n                                slug: 'specifications/backend/introduction',\n                            },\n                            {\n                                label: 'Endpoints',\n                                slug: 'specifications/backend/endpoints',\n                            },\n                            {\n                                label: 'API response format',\n                                slug: 'specifications/backend/api-response-format',\n                            },\n                            {\n                                label: 'CORS',\n                                slug: 'specifications/backend/cors',\n                            },\n                            {\n                                label: 'Error handling',\n                                slug: 'specifications/backend/error-handling',\n                            },\n                            {\n                                label: 'Hurl',\n                                slug: 'specifications/backend/hurl',\n                            },\n                            {\n                                label: 'Tests',\n                                slug: 'specifications/backend/tests',\n                            }\n                        ]\n                    },\n                    {\n                        label: 'Mobile specifications',\n                        slug: 'specifications/mobile-specs/introduction'\n                    }\n                ]\n            },\n            {\n                label: 'Community',\n                items: [\n                    // Each item here is one entry in the navigation menu.\n                    {\n                        label: 'Authors',\n                        slug: 'community/authors',\n                    },\n                    {\n                        label: 'Resources',\n                        slug: 'community/resources',\n                    },\n                    {\n                        label: 'Special Thanks',\n                        slug: 'community/special-thanks',\n                    }\n                ]\n            }\n        ]\n    })],\n    vite: {\n        plugins: [tailwindcss(), removeMdExtension()],\n        ssr: {\n            noExternal: ['@astrojs/starlight-tailwind'],\n        },\n    }\n});\n"
  },
  {
    "path": "docs/non-included/LICENSES_LOGOS.md",
    "content": "# Logo Licenses & Trademark Analysis\n\n> **Disclaimer**: This document was generated by Claude (Anthropic) and is provided\n> for informational purposes only. It does not constitute legal advice. Consult a\n> qualified attorney for legal guidance.\n\nThis document covers the licensing and trademark status of the framework logos\nused in the animated SVG (`frameworks.svg`) of this repository.\n\n## Individual Logo Licenses\n\n### Angular\n- **License**: CC BY 4.0 (Creative Commons Attribution 4.0 International)\n- **Source**: [angular.dev/press-kit](https://angular.dev/press-kit)\n- **Requirements**: Attribution to Google required\n- **Trademark**: \"ANGULAR\" is a registered trademark of Google LLC\n\n### React\n- **License**: CC BY 4.0 (Creative Commons Attribution 4.0 International)\n- **Source**: [github.com/facebook/react](https://github.com/facebook/react) / react.dev repo\n- **Requirements**: Attribution to Meta required\n- **Trademark**: React name and logo are trademarks of Meta Platforms, Inc.\n- **Reference**: Confirmed in [GitHub issue #12570](https://github.com/facebook/react/issues/12570)\n\n### Vue.js\n- **License**: CC BY-NC-SA 4.0 (Creative Commons Attribution-NonCommercial-ShareAlike 4.0)\n- **Source**: [github.com/vuejs/art](https://github.com/vuejs/art)\n- **Requirements**: Attribution, non-commercial use, share-alike on derivatives\n- **Permitted explicitly**: Open source / non-profit projects related to Vue.js\n- **Trademark**: VUE.JS is a registered US trademark owned by Yuxi (Evan) You\n\n### Django\n- **License**: No open license — governed by DSF Trademark Policy\n- **Source**: [djangoproject.com/community/logos](https://www.djangoproject.com/community/logos/)\n- **Requirements**:\n  - Must not imply endorsement by the Django Software Foundation\n  - Must not modify the logo (colors, proportions, added text)\n- **Trademark**: Registered trademark of the Django Software Foundation\n- **Reference**: [djangoproject.com/trademarks](https://www.djangoproject.com/trademarks/)\n\n### Hono\n- **License**: MIT (as part of the honojs/hono repository)\n- **Source**: [github.com/honojs/hono/docs/images](https://github.com/honojs/hono/tree/main/docs/images)\n- **Requirements**: Include MIT copyright notice\n- **Also available as**: CC0 1.0 (public domain) on third-party collections (gilbarbara/logos, Wikimedia Commons)\n\n## Combined License of the Animated SVG\n\nSince `frameworks.svg` is a derivative work incorporating all five logos, the\n**most restrictive compatible terms** apply to the combined work.\n\n### Governing constraints\n\n| Constraint        | Source         | Impact                                         |\n|-------------------|----------------|-------------------------------------------------|\n| **Attribution**   | Angular, React, Vue, Hono | Credit all projects and their respective owners |\n| **NonCommercial** | Vue.js (CC BY-NC-SA 4.0)  | The animated SVG cannot be used commercially    |\n| **ShareAlike**    | Vue.js (CC BY-NC-SA 4.0)  | Derivatives of the SVG must use CC BY-NC-SA 4.0 or compatible |\n| **No modification** | Django (trademark) | The Django logo within the SVG must remain unmodified (colors, proportions) |\n| **Linking**       | Django (trademark) | Logo should link to djangoproject.com \"wherever technically possible\" — not applicable here as the logo cycles within an animated SVG, making a dedicated hyperlink technically infeasible |\n| **No endorsement** | All (trademark) | Must not imply official affiliation with any project |\n\n### Effective license for this animated SVG\n\n**CC BY-NC-SA 4.0**, with additional trademark constraints from Django.\n\nThis means:\n- Free to share and adapt for **non-commercial** purposes\n- Must give **attribution** to all five projects\n- Derivatives must be shared under **the same license**\n- The Django logo portion must remain **unmodified** and **link to djangoproject.com** when possible\n- Usage must not suggest **endorsement** by any of the projects\n\n### Risk assessment\n\n| Logo    | Risk  | Notes                                                          |\n|---------|-------|----------------------------------------------------------------|\n| Angular | Low   | CC BY 4.0 is very permissive                                  |\n| React   | Low   | CC BY 4.0 is very permissive                                  |\n| Vue.js  | Low   | Explicitly allows open-source project use                     |\n| Django  | Medium | Trademark-only; animation of the logo could be seen as modification. We use minimal fade-in/out animation only, keeping the logo visually unaltered |\n| Hono    | Low   | MIT / CC0 — most permissive                                   |\n\n### Recommendation\n\nThe animated SVG is suitable for an open-source demo-apps repository, provided:\n1. This `LICENSES_LOGOS.md` file is kept in the repository for attribution\n2. The Django logo is not distorted, recolored, or visually altered (fade-in/out is acceptable)\n3. The Django logo links to djangoproject.com when technically possible\n4. The repository does not imply official endorsement by any of the five projects\n\n## Attribution\n\n- Angular logo by Google — [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)\n- React logo by Meta Platforms, Inc. — [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)\n- Vue.js logo by Evan You — [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/)\n- Django logo by the Django Software Foundation — [Trademark Policy](https://www.djangoproject.com/trademarks/)\n- Hono logo by Yusuke Wada — [MIT License](https://github.com/honojs/hono/blob/main/LICENSE)\n\n---\n\n*Generated by Claude (Anthropic)*\n"
  },
  {
    "path": "docs/package.json",
    "content": "{\n  \"name\": \"documentation\",\n  \"type\": \"module\",\n  \"version\": \"0.0.1\",\n  \"scripts\": {\n    \"dev\": \"astro dev\",\n    \"start\": \"astro dev\",\n    \"build\": \"astro check && astro build\",\n    \"preview\": \"astro preview\",\n    \"astro\": \"astro\"\n  },\n  \"dependencies\": {\n    \"@astrojs/check\": \"^0.9.6\",\n    \"@astrojs/react\": \"^4.4.2\",\n    \"@astrojs/starlight\": \"^0.37.4\",\n    \"@astrojs/starlight-tailwind\": \"^4.0.2\",\n    \"@tailwindcss/vite\": \"^4.1.8\",\n    \"@types/react\": \"^19.2.10\",\n    \"@types/react-dom\": \"^19.2.3\",\n    \"astro\": \"^5.16.16\",\n    \"react\": \"^19.2.4\",\n    \"react-dom\": \"^19.2.4\",\n    \"sharp\": \"^0.34.5\",\n    \"tailwindcss\": \"^4.1.18\",\n    \"typescript\": \"^5.9.3\"\n  }\n}\n"
  },
  {
    "path": "docs/src/content/config.ts",
    "content": "import { defineCollection } from 'astro:content';\nimport { docsSchema } from '@astrojs/starlight/schema';\n\nexport const collections = {\n\tdocs: defineCollection({ schema: docsSchema() }),\n};\n"
  },
  {
    "path": "docs/src/content/docs/community/authors.md",
    "content": "---\ntitle: Authors\n---\n\n# Who currently maintains the project\n\n#### [Gérôme Grignon](https://github.com/geromegrignon) - Maintainer\n\n<img class=\"mr-4\" align=\"left\" width=\"40\" height=\"40\" src=\"https://avatars.githubusercontent.com/u/32737308?v=4\">\n\nGérôme is a Software Engineer at Lucca. He's an open-source enthusiast.<br /><br />\n\n#### [Manuel Vila](https://github.com/mvila) - Maintainer\n\n<img class=\"mr-4\" align=\"left\" width=\"40\" height=\"40\" src=\"https://avatars.githubusercontent.com/u/381671?v=40\">\n\nManuel is an independent Software Engineer, creator of the [Layr framework](https://layrjs.com) and the [CodebaseShow website](https://codebase.show/).<br /><br />\n\n\n\n# Who created it\n\nRealWorld would not be possible without the [open source community](https://realworld-docs.netlify.app/docs/community/special-thanks) continuously helping push the project forward. In addition, the former team was composed of:\n\n#### [Anish Karandikar](https://github.com/anishkny) - Core Maintainer\n\n<img class=\"mr-4\" align=\"left\" width=\"40\" height=\"40\" src=\"https://avatars1.githubusercontent.com/u/357499?v=3&s=100\" />\n\nMathWorker, ex-Google, ex-Computational Fluid Dynamicist, forever lover of tech & humanities ❤️\n\n#### [Cameron Chapman](https://github.com/Cameron-C-Chapman) - Core Maintainer\n\n<img class=\"mr-4\" align=\"left\" width=\"40\" height=\"40\" src=\"https://avatars1.githubusercontent.com/u/1323581?v=3&s=100\" />\n\nCameron Chapman is a Software Engineer at FanThreeSixty. He's an open source enthusiast and is helping to teach a local web development boot camp at Kansas University.\n\n#### [Eric Simons](https://twitter.com/ericsimons40) - Founder/Maintainer\n\n<img class=\"mr-4\" align=\"left\" width=\"40\" height=\"40\" src=\"https://avatars1.githubusercontent.com/u/556934?v=3&s=100\" />\n\nEric is a Software Engineer, UI Designer, and author of many technical books & tutorials. He oversees the project direction, maintenance and organizes the planning and development efforts of the team.\n\n#### [Albert Pai](https://twitter.com/iamalbertpai) - Founder/Maintainer\n\n<img class=\"mr-4\" align=\"left\" width=\"40\" height=\"40\" src=\"https://avatars0.githubusercontent.com/u/1776432?v=3&s=100\" />\n\nAlbert is a Software Engineer, DevOps ninja, and author of many technical books & tutorials. He oversees the project direction, maintenance and organizes the planning and development efforts of the team.\n\n#### [Thinkster](https://twitter.com/gothinkster) - Funded\n\n<img class=\"mr-4\" align=\"left\" width=\"40\" height=\"40\" src=\"https://avatars0.githubusercontent.com/u/8601733?v=3&s=100\" />\n\n[Thinkster](https://x.com/GoThinkster) created high quality resources that helped Javascript developers succeed. The RealWorld project wouldn't exist without their funding!\n\n#### [James Brewer](https://twitter.com/brwr_) - Admin\n\n<img class=\"mr-4\" align=\"left\" width=\"40\" height=\"40\" src=\"https://avatars1.githubusercontent.com/u/4095660?v=3&s=100\" />\n\nJames is a Software Engineer at Square and a contributor to the Django project. He created & maintained the RW Django codebase and continually provides guidance for the RealWorld project itself.\n\n#### [Sandeesh S.](https://github.com/sandeesh) - Admin\n\n<img class=\"mr-4\" align=\"left\" width=\"40\" height=\"40\" src=\"https://avatars1.githubusercontent.com/u/16877877?v=3&s=100\" />\n\nFull stack developer, Laravel enthusiast, Digital marketing specialist and an avid gamer.\n"
  },
  {
    "path": "docs/src/content/docs/community/resources.md",
    "content": "---\ntitle: Resources\n---\n\n# Community created resources\n\n- Performance comparisons:\n  - [A Real-World Comparison of Front-End Frameworks with Benchmarks 2020](https://medium.com/dailyjs/a-realworld-comparison-of-front-end-frameworks-2020-4e50655fe4c1)\n  - [A Real-World Comparison of Front-End Frameworks with Benchmarks 2019](https://medium.freecodecamp.org/a-realworld-comparison-of-front-end-frameworks-with-benchmarks-2019-update-4be0d3c78075)\n  - [A Real-World Comparison of Front-End Frameworks with Benchmarks 2018](https://medium.freecodecamp.org/a-real-world-comparison-of-front-end-frameworks-with-benchmarks-2018-update-e5760fb4a962)\n  - [A Real-World Comparison of Front-End Frameworks with Benchmarks 2017](https://medium.freecodecamp.org/a-real-world-comparison-of-front-end-frameworks-with-benchmarks-e1cb62fd526c)\n\n:::tip\nHello fellow writer, get in touch with us in [**GitHub Discussions**](https://github.com/realworld-apps/realworld/discussions/categories/community) so we can add your RealWorld related content here.\n:::\n"
  },
  {
    "path": "docs/src/content/docs/community/special-thanks.md",
    "content": "---\ntitle: Special thanks\n---\n\nRealWorld would not be possible without the open source community's assistance in reviewing codebases, developing new app implementations, and a variety of other duties that help the project progress. We'd like to thank the following OSS leaders for their contributions to RealWorld:\n\n- **Dan Abramov** (creator of Redux) for helping [spark the initial idea](https://twitter.com/dan_abramov/status/692009757775896577), [getting the Redux community involved](https://github.com/reactjs/redux/issues/1353), as well as graciously taking the time to provide feedback on the Redux codebase\n- **Max Lynch** (creator of Ionic) for taking the time to provide guidance in the early days of this project\n- **Addy Osmani** (creator of TodoMVC) for helping [spark the initial idea](https://twitter.com/addyosmani/status/762828483433144320) and his amazing work with TodoMVC\n- **TodoMVC** ([team & contributors](https://github.com/tastejs/todomvc#team)) for their exemplary & successful work; their project & org has been an invaluable analogy for us as we've built out RealWorld\n- **James Brewer** (docs contributor to Django) for countless brainstorming sessions, helping name this project, and creating the Django codebase + tutorial\n"
  },
  {
    "path": "docs/src/content/docs/implementation-creation/expectations.md",
    "content": "---\ntitle: Expectations\n---\n\n## Remember: Keep your codebases _simple_, yet _robust_.\n\nIf a new developer to your framework comes along and takes longer than 10 minutes to grasp the high-level architecture, it's likely that you went a little overboard in the engineering department.\n\nAlternatively, you should _never_ forgo following fundamental best practices for the sake of simplicity, lest we teach that same newbie dev the _wrong_ way of doing things.\n\nThe quality & architecture of Conduit implementations should reflect something similar to an early-stage startup's MVP: functionally complete & stable, but not unnecessarily over-engineered.\n\n## To write tests, or to not write tests?\n\n**TL;DR** — we require a minimum of **one** unit test with every repo, but we'd definitely prefer all of them to include excellent testing coverage if the maintainers are willing to add it (or if someone in the community is kind enough to make a pull request :)\n\nWe believe that tests are a good concept, and we are big supporters of TDD in general. Building Conduit implementations without complete testing coverage, on the other hand, is a significant time commitment in and of itself, therefore we didn't include it in the spec at first since we believed that if people wanted it, it would be a fantastic \"extra credit\" aim for the repo. For example, a request for unit tests was made in our Angular 2 repo, and several fantastic community members are presently working on a PR to address it.\n\nAnother reason we didn’t include them in the spec is from the \"Golden Rule\" above:\n\n> The quality & architecture of Conduit implementations should reflect something similar to an early-stage startup's MVP: functionally complete & stable, but not unnecessarily over-engineered.\n\nMost startups we know that work in consumer-facing apps (like Conduit) don’t apply TDD/testing until they have a solid product-market fit, which is smart because they then spend most of their time iterating on product & UI and thus are far more likely to find PMF.\n\nThis doesn’t mean that TDD/testing === over-engineering, but in certain circumstances that statement does evaluate true (ex: consumer product finding PMF, side-projects, robust prototypes, etc).\n\nThat said, we do _prefer_ that every repo includes excellent tests that are exemplary of TDD/testing with that framework 👍\n\n## Other Expectations\n\n- All the required features (see specs) should be implemented.\n- You should publish your implementation on a dedicated GitHub repository with the \"Issues\" section open.\n- You should provide a README that presents an overview of your implementation and explains how to run it locally.\n- The library/framework you are using should have at least 300 GitHub stars.\n- You should do your best to keep your implementation up to date.\n"
  },
  {
    "path": "docs/src/content/docs/implementation-creation/features.md",
    "content": "---\ntitle: Features\n---\n\n**General functionality:**\n\n- Authenticate users via JWT (login/signup pages + logout button on settings page)\n- CRU- users (sign up & settings page - no deleting required)\n- CRUD Articles\n- CR-D Comments on articles (no updating required)\n- GET and display paginated lists of articles\n- Favorite articles\n- Follow other users\n"
  },
  {
    "path": "docs/src/content/docs/implementation-creation/introduction.md",
    "content": "---\ntitle: Introduction\n---\n\n**Conduit** is a social blogging site (i.e. a Medium.com clone). It uses a custom API for all requests, including authentication.\n\n:::tip\nCheck for [Discussions](https://github.com/realworld-apps/realworld/discussions/categories/wip-implementations) about works in progress as we don't list duplicate projects.  \nAn opportunity to collaborate might await you already.\n:::\n\nOtherwise:\n\n1. [fork our starter kit](https://github.com/gothinkster/realworld-starter-kit)\n2. Read the following sections: _expectations_ and _features_ for a better understanding of this project\n3. Read the frontend and/or the backend specs\n4. Submit the new implementation on [CodebaseShow](https://codebase.show/projects/realworld)\n\n**Happy coding!**\n"
  },
  {
    "path": "docs/src/content/docs/index.mdx",
    "content": "---\ntitle: RealWorld apps\ndescription: It's all about building real world, production ready apps.\ntemplate: splash\nhero:\n  tagline: |\n    While most \"todo\" demos provide an excellent cursory glance at a framework's capabilities, they typically don't convey the knowledge & perspective required to actually build real applications with it. That's why we, with the help of open source experts, design and serve as exemplary real world applications for each framework.\n  image:\n    file: ../../assets/img/realworld-logo.png\n  actions:\n    - text: Documentation\n      link: /introduction\n      icon: right-arrow\n---\n"
  },
  {
    "path": "docs/src/content/docs/introduction.mdx",
    "content": "---\ntitle: Introduction\n---\n\n# Introduction\n\n> See how _the exact same_ Medium.com clone is built using different [frontends](https://codebase.show/projects/realworld?category=frontend) and [backends](https://codebase.show/projects/realworld?category=backend). Yes, you can mix and match them, because **they all adhere to the same [API spec](/specifications/backend/introduction)** 😮😎\n\nWhile most \"todo\" demos provide an excellent cursory glance at a framework's capabilities, they typically don't convey the knowledge & perspective required to actually build _real_ applications with it.\n\n**RealWorld** solves this by allowing you to choose any frontend (React, Angular, & more) and any backend (Node, Django, & more).\n\n_Read the [full blog post announcing RealWorld on Medium.](https://medium.com/@ericsimons/introducing-realworld-6016654d36b5)_\n\nJoin us on [GitHub Discussions!](https://github.com/realworld-apps/realworld/discussions) 🎉\n\n## Implementations\n\nOver 150 implementations have been created using various languages, libraries, and frameworks.\n\nExplore them on [**CodebaseShow**](https://codebase.show/projects/realworld).\n\n## Create a new implementation\n\n[**Create a new implementation >>>**](implementation-creation/introduction)\n\nOr you can [view upcoming implementations (WIPs)](https://github.com/realworld-apps/realworld/discussions/categories/wip-implementations).\n\n## Learn more\n\n- [\"Introducing RealWorld 🙌\"](https://medium.com/@ericsimons/introducing-realworld-6016654d36b5) by Eric Simons\n- Every tutorial is built against the same [API spec](/specifications/backend/introduction) to ensure modularity of every frontend & backend\n- Every frontend utilizes the same hand crafted [Bootstrap 4 theme](https://github.com/gothinkster/conduit-bootstrap-template) for identical UI/UX\n- There is a [hosted version](https://realworld-docs.netlify.app/docs/specs/frontend-specs/api#demo-api) of the backend API available for public usage, no API keys are required\n- Interested in creating a new RealWorld stack? View our [starter guide & spec](/implementation-creation/introduction)\n"
  },
  {
    "path": "docs/src/content/docs/specifications/backend/api-response-format.md",
    "content": "---\ntitle: API response format\n---\n\n## JSON Objects returned by API:\n\nMake sure the right content type like `Content-Type: application/json; charset=utf-8` is correctly returned.\n\n### Users (for authentication)\n\n```json\n{\n  \"user\": {\n    \"email\": \"jake@jake.jake\",\n    \"token\": \"jwt.token.here\",\n    \"username\": \"jake\",\n    \"bio\": null,\n    \"image\": null\n  }\n}\n```\n\n### Profile\n\n```json\n{\n  \"profile\": {\n    \"username\": \"jake\",\n    \"bio\": \"I work at statefarm\",\n    \"image\": \"https://api.realworld.io/images/smiley-cyrus.jpg\",\n    \"following\": false\n  }\n}\n```\n\n### Single Article\n\n```json\n{\n  \"article\": {\n    \"slug\": \"how-to-train-your-dragon\",\n    \"title\": \"How to train your dragon\",\n    \"description\": \"Ever wonder how?\",\n    \"body\": \"It takes a Jacobian\",\n    \"tagList\": [\"dragons\", \"training\"],\n    \"createdAt\": \"2016-02-18T03:22:56.637Z\",\n    \"updatedAt\": \"2016-02-18T03:48:35.824Z\",\n    \"favorited\": false,\n    \"favoritesCount\": 0,\n    \"author\": {\n      \"username\": \"jake\",\n      \"bio\": \"I work at statefarm\",\n      \"image\": \"https://i.stack.imgur.com/xHWG8.jpg\",\n      \"following\": false\n    }\n  }\n}\n```\n\n### Multiple Articles\n\n:::caution\nStarting from the 2024/08/16, the endpoints retrieving a list of articles do no longer return the body of an article for performance reasons.\nIt affcts: \n- `GET /api/articles`\n- `GET /api/articles/feed`\n:::\n\n```json\n{\n  \"articles\":[{\n    \"slug\": \"how-to-train-your-dragon\",\n    \"title\": \"How to train your dragon\",\n    \"description\": \"Ever wonder how?\",\n    \"tagList\": [\"dragons\", \"training\"],\n    \"createdAt\": \"2016-02-18T03:22:56.637Z\",\n    \"updatedAt\": \"2016-02-18T03:48:35.824Z\",\n    \"favorited\": false,\n    \"favoritesCount\": 0,\n    \"author\": {\n      \"username\": \"jake\",\n      \"bio\": \"I work at statefarm\",\n      \"image\": \"https://i.stack.imgur.com/xHWG8.jpg\",\n      \"following\": false\n    }\n  }, {\n    \"slug\": \"how-to-train-your-dragon-2\",\n    \"title\": \"How to train your dragon 2\",\n    \"description\": \"So toothless\",\n    \"tagList\": [\"dragons\", \"training\"],\n    \"createdAt\": \"2016-02-18T03:22:56.637Z\",\n    \"updatedAt\": \"2016-02-18T03:48:35.824Z\",\n    \"favorited\": false,\n    \"favoritesCount\": 0,\n    \"author\": {\n      \"username\": \"jake\",\n      \"bio\": \"I work at statefarm\",\n      \"image\": \"https://i.stack.imgur.com/xHWG8.jpg\",\n      \"following\": false\n    }\n  }],\n  \"articlesCount\": 2\n}\n```\n\n### Single Comment\n\n```json\n{\n  \"comment\": {\n    \"id\": 1,\n    \"createdAt\": \"2016-02-18T03:22:56.637Z\",\n    \"updatedAt\": \"2016-02-18T03:22:56.637Z\",\n    \"body\": \"It takes a Jacobian\",\n    \"author\": {\n      \"username\": \"jake\",\n      \"bio\": \"I work at statefarm\",\n      \"image\": \"https://i.stack.imgur.com/xHWG8.jpg\",\n      \"following\": false\n    }\n  }\n}\n```\n\n### Multiple Comments\n\n```json\n{\n  \"comments\": [{\n    \"id\": 1,\n    \"createdAt\": \"2016-02-18T03:22:56.637Z\",\n    \"updatedAt\": \"2016-02-18T03:22:56.637Z\",\n    \"body\": \"It takes a Jacobian\",\n    \"author\": {\n      \"username\": \"jake\",\n      \"bio\": \"I work at statefarm\",\n      \"image\": \"https://i.stack.imgur.com/xHWG8.jpg\",\n      \"following\": false\n    }\n  }]\n}\n```\n\n### List of Tags\n\n```json\n{\n  \"tags\": [\n    \"reactjs\",\n    \"angularjs\"\n  ]\n}\n```\n"
  },
  {
    "path": "docs/src/content/docs/specifications/backend/bruno.md",
    "content": "---\ntitle: Bruno\n---\n\nFor your convenience, we have a [Bruno collection](https://github.com/realworld-apps/realworld/tree/main/specs/api/bruno) that you can use to test your API endpoints as you build your app. You can run them all with [`run-api-tests-bruno.sh`](https://github.com/realworld-apps/realworld/blob/main/specs/api/run-api-tests-bruno.sh), or open the `bruno/` folder in the [Bruno app](https://www.usebruno.com) to run requests interactively.\n\nThe Bruno collection is automatically generated from the [Hurl test suite](/specifications/backend/hurl), which is the source of truth. It is kept in sync via CI.\n\n## Running API tests locally\n\nTo locally run the provided Bruno collection against your backend, follow instructions [here](https://github.com/realworld-apps/realworld/tree/main/specs/api).\n"
  },
  {
    "path": "docs/src/content/docs/specifications/backend/cors.md",
    "content": "---\ntitle: CORS\n---\n\n## Considerations for your backend with [CORS](https://en.wikipedia.org/wiki/Cross-origin_resource_sharing)\n\nIf the backend is about to run on a different host/port than the frontend, make sure to handle `OPTIONS` too and return correct `Access-Control-Allow-Origin` and `Access-Control-Allow-Headers` (e.g. `Content-Type`).\n"
  },
  {
    "path": "docs/src/content/docs/specifications/backend/endpoints.md",
    "content": "---\ntitle: Endpoints\n---\n\n### Authentication Header:\n\nYou can read the authentication header from the headers of the request\n\n`Authorization: Token jwt.token.here`\n\n### Authentication:\n\n`POST /api/users/login`\n\nExample request body:\n\n```json\n{\n  \"user\":{\n    \"email\": \"jake@jake.jake\",\n    \"password\": \"jakejake\"\n  }\n}\n```\n\nNo authentication required, returns a [User](/specifications/backend/api-response-format#users-for-authentication)\n\nRequired fields: `email`, `password`\n\n### Registration:\n\n`POST /api/users`\n\nExample request body:\n\n```json\n{\n  \"user\":{\n    \"username\": \"Jacob\",\n    \"email\": \"jake@jake.jake\",\n    \"password\": \"jakejake\"\n  }\n}\n```\n\nNo authentication required, returns a [User](/specifications/backend/api-response-format#users-for-authentication)\n\nRequired fields: `email`, `username`, `password`\n\n### Get Current User\n\n`GET /api/user`\n\nAuthentication required, returns a [User](/specifications/backend/api-response-format#users-for-authentication) that's the current user\n\n### Update User\n\n`PUT /api/user`\n\nExample request body:\n\n```json\n{\n  \"user\":{\n    \"email\": \"jake@jake.jake\",\n    \"bio\": \"I like to skateboard\",\n    \"image\": \"https://i.stack.imgur.com/xHWG8.jpg\"\n  }\n}\n```\n\nAuthentication required, returns the [User](/specifications/backend/api-response-format#users-for-authentication)\n\nAccepted fields: `email`, `username`, `password`, `image`, `bio`\n\n### Get Profile\n\n`GET /api/profiles/:username`\n\nAuthentication optional, returns a [Profile](/specifications/backend/api-response-format#profile)\n\n### Follow user\n\n`POST /api/profiles/:username/follow`\n\nAuthentication required, returns a [Profile](/specifications/backend/api-response-format#profile)\n\nNo additional parameters required\n\n### Unfollow user\n\n`DELETE /api/profiles/:username/follow`\n\nAuthentication required, returns a [Profile](/specifications/backend/api-response-format#profile)\n\nNo additional parameters required\n\n### List Articles\n\n`GET /api/articles`\n\nReturns most recent articles globally by default, provide `tag`, `author` or `favorited` query parameter to filter results\n\nQuery Parameters:\n\nFilter by tag:\n\n`?tag=AngularJS`\n\nFilter by author:\n\n`?author=jake`\n\nFavorited by user:\n\n`?favorited=jake`\n\nLimit number of articles (default is 20):\n\n`?limit=20`\n\nOffset/skip number of articles (default is 0):\n\n`?offset=0`\n\nAuthentication optional, will return [multiple articles](/specifications/backend/api-response-format#multiple-articles), ordered by most recent first\n\n### Feed Articles\n\n`GET /api/articles/feed`\n\nCan also take `limit` and `offset` query parameters like [List Articles](/specifications/backend/api-response-format#list-articles)\n\nAuthentication required, will return [multiple articles](/specifications/backend/api-response-format#multiple-articles) created by followed users, ordered by most recent first.\n\n### Get Article\n\n`GET /api/articles/:slug`\n\nNo authentication required, will return [single article](/specifications/backend/api-response-format#single-article)\n\n### Create Article\n\n`POST /api/articles`\n\nExample request body:\n\n```json\n{\n  \"article\": {\n    \"title\": \"How to train your dragon\",\n    \"description\": \"Ever wonder how?\",\n    \"body\": \"You have to believe\",\n    \"tagList\": [\"reactjs\", \"angularjs\", \"dragons\"]\n  }\n}\n```\n\nAuthentication required, will return an [Article](/specifications/backend/api-response-format#single-article)\n\nRequired fields: `title`, `description`, `body`\n\nOptional fields: `tagList` as an array of Strings\n\n### Update Article\n\n`PUT /api/articles/:slug`\n\nExample request body:\n\n```json\n{\n  \"article\": {\n    \"title\": \"Did you train your dragon?\"\n  }\n}\n```\n\nAuthentication required, returns the updated [Article](/specifications/backend/api-response-format#single-article)\n\nOptional fields: `title`, `description`, `body`\n\nThe `slug` also gets updated when the `title` is changed\n\n### Delete Article\n\n`DELETE /api/articles/:slug`\n\nAuthentication required\n\n### Add Comments to an Article\n\n`POST /api/articles/:slug/comments`\n\nExample request body:\n\n```json\n{\n  \"comment\": {\n    \"body\": \"His name was my name too.\"\n  }\n}\n```\n\nAuthentication required, returns the created [Comment](/specifications/backend/api-response-format#single-comment)\n\nRequired field: `body`\n\n### Get Comments from an Article\n\n`GET /api/articles/:slug/comments`\n\nAuthentication optional, returns [multiple comments](/specifications/backend/api-response-format#multiple-comments)\n\n### Delete Comment\n\n`DELETE /api/articles/:slug/comments/:id`\n\nAuthentication required\n\n### Favorite Article\n\n`POST /api/articles/:slug/favorite`\n\nAuthentication required, returns the [Article](/specifications/backend/api-response-format#single-article)\n\nNo additional parameters required\n\n### Unfavorite Article\n\n`DELETE /api/articles/:slug/favorite`\n\nAuthentication required, returns the [Article](/specifications/backend/api-response-format#single-article)\n\nNo additional parameters required\n\n### Get Tags\n\n`GET /api/tags`\n\nNo authentication required, returns a [List of Tags](/specifications/backend/api-response-format#list-of-tags)\n"
  },
  {
    "path": "docs/src/content/docs/specifications/backend/error-handling.md",
    "content": "---\ntitle: Error handling\n---\n\n### Errors and Status Codes\n\nIf a request fails any validations, expect a 422 and errors in the following format:\n\n```json\n{\n  \"errors\":{\n    \"body\": [\n      \"can't be empty\"\n    ]\n  }\n}\n```\n\n#### Other status codes:\n\n401 for Unauthorized requests, when a request requires authentication but it isn't provided\n\n403 for Forbidden requests, when a request may be valid but the user doesn't have permissions to perform the action\n\n404 for Not found requests, when a resource can't be found to fulfill the request\n"
  },
  {
    "path": "docs/src/content/docs/specifications/backend/hurl.md",
    "content": "---\ntitle: Hurl\n---\n\nFor your convenience, we have a [Hurl collection](https://github.com/realworld-apps/realworld/tree/main/specs/api/hurl) that you can use to test your API endpoints as you build your app. You can run them all with [`run-api-tests-hurl.sh`](https://github.com/realworld-apps/realworld/blob/main/specs/api/run-api-tests-hurl.sh).\n\nA [Bruno collection](/specifications/backend/bruno) is also available if you prefer a GUI-based workflow.\n\n## Running API tests locally\n\nTo locally run the provided Hurl collection against your backend, follow instructions [here](https://github.com/realworld-apps/realworld/tree/main/specs/api).\n"
  },
  {
    "path": "docs/src/content/docs/specifications/backend/introduction.md",
    "content": "---\ntitle: Introduction\n---\n\nAll backend implementations need to adhere to our [API spec](https://github.com/realworld-apps/realworld/tree/main/specs/api). The full API is described in the [OpenAPI spec](https://github.com/realworld-apps/realworld/blob/main/specs/api/openapi.yml).\n\nFor your convenience, we have a [Hurl collection](https://github.com/realworld-apps/realworld/tree/main/specs/api/hurl) that you can use to test your API endpoints as you build your app. You can run them all with [`run-api-tests-hurl.sh`](https://github.com/realworld-apps/realworld/blob/main/specs/api/run-api-tests-hurl.sh).\n\nCheck out our [starter kit](https://github.com/gothinkster/realworld-starter-kit) to create a new implementation, please read [references to the API specs & testing](/specifications/backend/introduction) required for creating a new backend.\n"
  },
  {
    "path": "docs/src/content/docs/specifications/backend/postman.md",
    "content": "---\ntitle: Postman\n---\n\nFor your convenience, we have a [Postman collection](https://github.com/realworld-apps/realworld/blob/main/specs/api/legacy_Conduit.postman_collection.json) that you can use to test your API endpoints as you build your app.\n\n## Running API tests locally\n\nTo locally run the provided Postman collection against your backend, follow instructions [here](https://github.com/realworld-apps/realworld/tree/main/specs/api).\n"
  },
  {
    "path": "docs/src/content/docs/specifications/backend/tests.md",
    "content": "---\ntitle: Tests\n---\n\nInclude _at least_ **one** unit test in your repo to demonstrate how testing works (full testing coverage is _not_ required!)\n"
  },
  {
    "path": "docs/src/content/docs/specifications/frontend/api.md",
    "content": "---\ntitle: API\n---\n\nThis project provides you different solutions to test your frontend implementation with an API by:\n\n- [running our official backend implementation locally](#run-the-official-backend-implementation-locally)\n- [using the API deployed for the official demo](#demo-api)\n\n## Run the official backend implementation locally\n\nThe official backend implementation is open-sourced.  \nYou can find the GitHub repository [here](https://github.com/realworld-apps/nitro-prisma-zod-realworld-example-app).\nThe Readme will provide you guidances to start the server locally.\n\n## Demo API\n\nThis project provides you with a public hosted API to test your frontend implementations.  \nPoint your API requests to `https://api.realworld.show/api` and you're good to go!\n\n### API Usage\n\nThe API is freely available for public usage but its access is limited to RealWorld usage only: you won't be able to t consume it on its own but with a frontend application.\n\n## API Limitations\n\n:::info\nTo avoid the need for content moderation on the public API, the following limitations have been introduced in 2021\n:::\n\nThe visibility of user content is limited :\n\n- logged out users see only content created by demo accounts\n- logged in users see only their content and the content created by demo accounts\n"
  },
  {
    "path": "docs/src/content/docs/specifications/frontend/routing.md",
    "content": "---\ntitle: Routing\n---\n\n- Home page (URL: `/` )\n  - List of tags\n  - List of articles pulled from either Feed, Global, or by Tag\n  - Pagination for list of articles\n- Sign in/Sign up pages (URL: `/login`, `/register` )\n  - Uses JWT (store the token in localStorage)\n  - Authentication can be easily switched to session/cookie based\n- Settings page (URL: `/settings` )\n- Editor page to create/edit articles (URL: `/editor`, `/editor/article-slug-here` )\n- Article page (URL: `/article/article-slug-here` )\n  - Delete article button (only shown to article's author)\n  - Render markdown from server client side\n  - Comments section at bottom of page\n  - Delete comment button (only shown to comment's author)\n- Profile page (URL: `/profile/:username`, `/profile/:username/favorites` )\n  - Show basic user info\n  - List of articles populated from author's created articles or author's favorited articles\n"
  },
  {
    "path": "docs/src/content/docs/specifications/frontend/styles.md",
    "content": "---\ntitle: Styles\n---\n\nAll frontend implementations should use the shared [styles.css](https://github.com/realworld-apps/realworld/blob/main/assets/theme/styles.css) file from the main repository. This is a self-contained CSS file (Conduit Minimal CSS v4) that includes only the classes actually used by Conduit.\n\nThe CSS classes it provides match the [templates](/specifications/frontend/templates) and the [E2E test selectors contract](https://github.com/realworld-apps/realworld/blob/main/specs/e2e/SELECTORS.md).\n\n### Default Avatar\n\nWhen a user has no profile image, implementations should display the [default avatar](https://github.com/realworld-apps/realworld/blob/main/assets/media/default-avatar.svg) (a smiley face icon).\n"
  },
  {
    "path": "docs/src/content/docs/specifications/frontend/templates.md",
    "content": "---\ntitle: Templates\n---\n\n## Head\n\nThe `<head>` element includes all the metadata for a page, including the title, description, and links to stylesheets and scripts.\n\n```html\n<head>\n  <meta charset=\"utf-8\" />\n  <title>Conduit</title>\n  <!-- Import Ionicon icons & Google Fonts our Bootstrap theme relies on -->\n  <link\n    href=\"//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css\"\n    rel=\"stylesheet\"\n    type=\"text/css\"\n  />\n  <link\n    href=\"//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic\"\n    rel=\"stylesheet\"\n    type=\"text/css\"\n  />\n  <!-- Import the custom Bootstrap 4 theme from our hosted CDN -->\n  <link rel=\"stylesheet\" href=\"//demo.productionready.io/main.css\" />\n</head>\n```\n\n## Layout\n\n### Header\n\n#### Unauthenticated user\n\nIf no user is logged in, then the header should include links to:\n\n- the home page\n- the login page\n- the register page\n\n> the link of the active page should use the **active** css class.\n\n```html\n<nav class=\"navbar navbar-light\">\n  <div class=\"container\">\n    <a class=\"navbar-brand\" href=\"/\">conduit</a>\n    <ul class=\"nav navbar-nav pull-xs-right\">\n      <li class=\"nav-item\">\n        <!-- Add \"active\" class when you're on that page\" -->\n        <a class=\"nav-link active\" href=\"/\">Home</a>\n      </li>\n      <li class=\"nav-item\">\n        <a class=\"nav-link\" href=\"/login\">Sign in</a>\n      </li>\n      <li class=\"nav-item\">\n        <a class=\"nav-link\" href=\"/register\">Sign up</a>\n      </li>\n    </ul>\n  </div>\n</nav>\n```\n\n#### Authenticated user\n\nIf a user is logged in, then the header should include links to:\n\n- the home page\n- the new article page\n- the settings page\n- the profile page\n\n> the link of the active page should use the **active** css class.\n\n```html\n<nav class=\"navbar navbar-light\">\n  <div class=\"container\">\n    <a class=\"navbar-brand\" href=\"/\">conduit</a>\n    <ul class=\"nav navbar-nav pull-xs-right\">\n      <li class=\"nav-item\">\n        <!-- Add \"active\" class when you're on that page\" -->\n        <a class=\"nav-link active\" href=\"/\">Home</a>\n      </li>\n      <li class=\"nav-item\">\n        <a class=\"nav-link\" href=\"/editor\"> <i class=\"ion-compose\"></i>&nbsp;New Article </a>\n      </li>\n      <li class=\"nav-item\">\n        <a class=\"nav-link\" href=\"/settings\"> <i class=\"ion-gear-a\"></i>&nbsp;Settings </a>\n      </li>\n      <li class=\"nav-item\">\n        <a class=\"nav-link\" href=\"/profile/eric-simons\">\n          <img src=\"\" class=\"user-pic\" />\n          Eric Simons\n        </a>\n      </li>\n    </ul>\n  </div>\n</nav>\n```\n\n### Footer\n\n```html\n<footer>\n  <div class=\"container\">\n    <a href=\"/\" class=\"logo-font\">conduit</a>\n    <span class=\"attribution\">\n      An interactive learning project. Code &amp; design licensed under MIT.\n    </span>\n  </div>\n</footer>\n```\n\n## Pages\n\n### Home\n\nThe Home page includes up to three tabs:\n\n- a default **Global Feed** tab\n- an optional **tag name** tab, appears after clicking one of the popular tags\n- an optional **Your Feed** tab, appears after logging in\n\n```html\n<div class=\"home-page\">\n  <div class=\"banner\">\n    <div class=\"container\">\n      <h1 class=\"logo-font\">conduit</h1>\n      <p>A place to share your knowledge.</p>\n    </div>\n  </div>\n\n  <div class=\"container page\">\n    <div class=\"row\">\n      <div class=\"col-md-9\">\n        <div class=\"feed-toggle\">\n          <ul class=\"nav nav-pills outline-active\">\n            <li class=\"nav-item\">\n              <a class=\"nav-link\" href=\"\">Your Feed</a>\n            </li>\n            <li class=\"nav-item\">\n              <a class=\"nav-link active\" href=\"\">Global Feed</a>\n            </li>\n          </ul>\n        </div>\n\n        <div class=\"article-preview\">\n          <div class=\"article-meta\">\n            <a href=\"/profile/eric-simons\"><img src=\"http://i.imgur.com/Qr71crq.jpg\" /></a>\n            <div class=\"info\">\n              <a href=\"/profile/eric-simons\" class=\"author\">Eric Simons</a>\n              <span class=\"date\">January 20th</span>\n            </div>\n            <button class=\"btn btn-outline-primary btn-sm pull-xs-right\">\n              <i class=\"ion-heart\"></i> 29\n            </button>\n          </div>\n          <a href=\"/article/how-to-build-webapps-that-scale\" class=\"preview-link\">\n            <h1>How to build webapps that scale</h1>\n            <p>This is the description for the post.</p>\n            <span>Read more...</span>\n            <ul class=\"tag-list\">\n              <li class=\"tag-default tag-pill tag-outline\">realworld</li>\n              <li class=\"tag-default tag-pill tag-outline\">implementations</li>\n            </ul>\n          </a>\n        </div>\n\n        <div class=\"article-preview\">\n          <div class=\"article-meta\">\n            <a href=\"/profile/albert-pai\"><img src=\"http://i.imgur.com/N4VcUeJ.jpg\" /></a>\n            <div class=\"info\">\n              <a href=\"/profile/albert-pai\" class=\"author\">Albert Pai</a>\n              <span class=\"date\">January 20th</span>\n            </div>\n            <button class=\"btn btn-outline-primary btn-sm pull-xs-right\">\n              <i class=\"ion-heart\"></i> 32\n            </button>\n          </div>\n          <a href=\"/article/the-song-you\" class=\"preview-link\">\n            <h1>The song you won't ever stop singing. No matter how hard you try.</h1>\n            <p>This is the description for the post.</p>\n            <span>Read more...</span>\n            <ul class=\"tag-list\">\n              <li class=\"tag-default tag-pill tag-outline\">realworld</li>\n              <li class=\"tag-default tag-pill tag-outline\">implementations</li>\n            </ul>\n          </a>\n        </div>\n\n        <ul class=\"pagination\">\n          <li class=\"page-item active\">\n            <a class=\"page-link\" href=\"\">1</a>\n          </li>\n          <li class=\"page-item\">\n            <a class=\"page-link\" href=\"\">2</a>\n          </li>\n        </ul>\n      </div>\n\n      <div class=\"col-md-3\">\n        <div class=\"sidebar\">\n          <p>Popular Tags</p>\n\n          <div class=\"tag-list\">\n            <a href=\"\" class=\"tag-pill tag-default\">programming</a>\n            <a href=\"\" class=\"tag-pill tag-default\">javascript</a>\n            <a href=\"\" class=\"tag-pill tag-default\">emberjs</a>\n            <a href=\"\" class=\"tag-pill tag-default\">angularjs</a>\n            <a href=\"\" class=\"tag-pill tag-default\">react</a>\n            <a href=\"\" class=\"tag-pill tag-default\">mean</a>\n            <a href=\"\" class=\"tag-pill tag-default\">node</a>\n            <a href=\"\" class=\"tag-pill tag-default\">rails</a>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</div>\n```\n\n### Authentication\n\n#### Login\n\n```html\n<div class=\"auth-page\">\n  <div class=\"container page\">\n    <div class=\"row\">\n      <div class=\"col-md-6 offset-md-3 col-xs-12\">\n        <h1 class=\"text-xs-center\">Sign in</h1>\n        <p class=\"text-xs-center\">\n          <a href=\"/register\">Need an account?</a>\n        </p>\n\n        <ul class=\"error-messages\">\n          <li>That email is already taken</li>\n        </ul>\n\n        <form>\n          <fieldset class=\"form-group\">\n            <input class=\"form-control form-control-lg\" type=\"text\" placeholder=\"Email\" />\n          </fieldset>\n          <fieldset class=\"form-group\">\n            <input class=\"form-control form-control-lg\" type=\"password\" placeholder=\"Password\" />\n          </fieldset>\n          <button class=\"btn btn-lg btn-primary pull-xs-right\">Sign in</button>\n        </form>\n      </div>\n    </div>\n  </div>\n</div>\n```\n\n#### Register\n\n```html\n<div class=\"auth-page\">\n  <div class=\"container page\">\n    <div class=\"row\">\n      <div class=\"col-md-6 offset-md-3 col-xs-12\">\n        <h1 class=\"text-xs-center\">Sign up</h1>\n        <p class=\"text-xs-center\">\n          <a href=\"/login\">Have an account?</a>\n        </p>\n\n        <ul class=\"error-messages\">\n          <li>That email is already taken</li>\n        </ul>\n\n        <form>\n          <fieldset class=\"form-group\">\n            <input class=\"form-control form-control-lg\" type=\"text\" placeholder=\"Username\" />\n          </fieldset>\n          <fieldset class=\"form-group\">\n            <input class=\"form-control form-control-lg\" type=\"text\" placeholder=\"Email\" />\n          </fieldset>\n          <fieldset class=\"form-group\">\n            <input class=\"form-control form-control-lg\" type=\"password\" placeholder=\"Password\" />\n          </fieldset>\n          <button class=\"btn btn-lg btn-primary pull-xs-right\">Sign up</button>\n        </form>\n      </div>\n    </div>\n  </div>\n</div>\n```\n\n### Profile\n\n```html\n<div class=\"profile-page\">\n  <div class=\"user-info\">\n    <div class=\"container\">\n      <div class=\"row\">\n        <div class=\"col-xs-12 col-md-10 offset-md-1\">\n          <img src=\"http://i.imgur.com/Qr71crq.jpg\" class=\"user-img\" />\n          <h4>Eric Simons</h4>\n          <p>\n            Cofounder @GoThinkster, lived in Aol's HQ for a few months, kinda looks like Peeta from\n            the Hunger Games\n          </p>\n          <button class=\"btn btn-sm btn-outline-secondary action-btn\">\n            <i class=\"ion-plus-round\"></i>\n            &nbsp; Follow Eric Simons\n          </button>\n          <button class=\"btn btn-sm btn-outline-secondary action-btn\">\n            <i class=\"ion-gear-a\"></i>\n            &nbsp; Edit Profile Settings\n          </button>\n        </div>\n      </div>\n    </div>\n  </div>\n\n  <div class=\"container\">\n    <div class=\"row\">\n      <div class=\"col-xs-12 col-md-10 offset-md-1\">\n        <div class=\"articles-toggle\">\n          <ul class=\"nav nav-pills outline-active\">\n            <li class=\"nav-item\">\n              <a class=\"nav-link active\" href=\"\">My Articles</a>\n            </li>\n            <li class=\"nav-item\">\n              <a class=\"nav-link\" href=\"\">Favorited Articles</a>\n            </li>\n          </ul>\n        </div>\n\n        <div class=\"article-preview\">\n          <div class=\"article-meta\">\n            <a href=\"/profile/eric-simons\"><img src=\"http://i.imgur.com/Qr71crq.jpg\" /></a>\n            <div class=\"info\">\n              <a href=\"/profile/eric-simons\" class=\"author\">Eric Simons</a>\n              <span class=\"date\">January 20th</span>\n            </div>\n            <button class=\"btn btn-outline-primary btn-sm pull-xs-right\">\n              <i class=\"ion-heart\"></i> 29\n            </button>\n          </div>\n          <a href=\"/article/how-to-buil-webapps-that-scale\" class=\"preview-link\">\n            <h1>How to build webapps that scale</h1>\n            <p>This is the description for the post.</p>\n            <span>Read more...</span>\n            <ul class=\"tag-list\">\n              <li class=\"tag-default tag-pill tag-outline\">realworld</li>\n              <li class=\"tag-default tag-pill tag-outline\">implementations</li>\n            </ul>\n          </a>\n        </div>\n\n        <div class=\"article-preview\">\n          <div class=\"article-meta\">\n            <a href=\"/profile/albert-pai\"><img src=\"http://i.imgur.com/N4VcUeJ.jpg\" /></a>\n            <div class=\"info\">\n              <a href=\"/profile/albert-pai\" class=\"author\">Albert Pai</a>\n              <span class=\"date\">January 20th</span>\n            </div>\n            <button class=\"btn btn-outline-primary btn-sm pull-xs-right\">\n              <i class=\"ion-heart\"></i> 32\n            </button>\n          </div>\n          <a href=\"/article/the-song-you\" class=\"preview-link\">\n            <h1>The song you won't ever stop singing. No matter how hard you try.</h1>\n            <p>This is the description for the post.</p>\n            <span>Read more...</span>\n            <ul class=\"tag-list\">\n              <li class=\"tag-default tag-pill tag-outline\">Music</li>\n              <li class=\"tag-default tag-pill tag-outline\">Song</li>\n            </ul>\n          </a>\n        </div>\n\n        <ul class=\"pagination\">\n          <li class=\"page-item active\">\n            <a class=\"page-link\" href=\"\">1</a>\n          </li>\n          <li class=\"page-item\">\n            <a class=\"page-link\" href=\"\">2</a>\n          </li>\n        </ul>\n      </div>\n    </div>\n  </div>\n</div>\n```\n\n### Settings\n\n```html\n<div class=\"settings-page\">\n  <div class=\"container page\">\n    <div class=\"row\">\n      <div class=\"col-md-6 offset-md-3 col-xs-12\">\n        <h1 class=\"text-xs-center\">Your Settings</h1>\n\n        <ul class=\"error-messages\">\n          <li>That name is required</li>\n        </ul>\n\n        <form>\n          <fieldset>\n            <fieldset class=\"form-group\">\n              <input class=\"form-control\" type=\"text\" placeholder=\"URL of profile picture\" />\n            </fieldset>\n            <fieldset class=\"form-group\">\n              <input class=\"form-control form-control-lg\" type=\"text\" placeholder=\"Your Name\" />\n            </fieldset>\n            <fieldset class=\"form-group\">\n              <textarea\n                class=\"form-control form-control-lg\"\n                rows=\"8\"\n                placeholder=\"Short bio about you\"\n              ></textarea>\n            </fieldset>\n            <fieldset class=\"form-group\">\n              <input class=\"form-control form-control-lg\" type=\"text\" placeholder=\"Email\" />\n            </fieldset>\n            <fieldset class=\"form-group\">\n              <input\n                class=\"form-control form-control-lg\"\n                type=\"password\"\n                placeholder=\"New Password\"\n              />\n            </fieldset>\n            <button class=\"btn btn-lg btn-primary pull-xs-right\">Update Settings</button>\n          </fieldset>\n        </form>\n        <hr />\n        <button class=\"btn btn-outline-danger\">Or click here to logout.</button>\n      </div>\n    </div>\n  </div>\n</div>\n```\n\n### Create/Edit Article\n\n```html\n<div class=\"editor-page\">\n  <div class=\"container page\">\n    <div class=\"row\">\n      <div class=\"col-md-10 offset-md-1 col-xs-12\">\n        <ul class=\"error-messages\">\n          <li>That title is required</li>\n        </ul>\n\n        <form>\n          <fieldset>\n            <fieldset class=\"form-group\">\n              <input type=\"text\" class=\"form-control form-control-lg\" placeholder=\"Article Title\" />\n            </fieldset>\n            <fieldset class=\"form-group\">\n              <input type=\"text\" class=\"form-control\" placeholder=\"What's this article about?\" />\n            </fieldset>\n            <fieldset class=\"form-group\">\n              <textarea\n                class=\"form-control\"\n                rows=\"8\"\n                placeholder=\"Write your article (in markdown)\"\n              ></textarea>\n            </fieldset>\n            <fieldset class=\"form-group\">\n              <input type=\"text\" class=\"form-control\" placeholder=\"Enter tags\" />\n              <div class=\"tag-list\">\n                <span class=\"tag-default tag-pill\"> <i class=\"ion-close-round\"></i> tag </span>\n              </div>\n            </fieldset>\n            <button class=\"btn btn-lg pull-xs-right btn-primary\" type=\"button\">\n              Publish Article\n            </button>\n          </fieldset>\n        </form>\n      </div>\n    </div>\n  </div>\n</div>\n```\n\n### Article\n\n// TODO : update to switch between follow/favorite AND edit/delete\n\n```html\n<div class=\"article-page\">\n  <div class=\"banner\">\n    <div class=\"container\">\n      <h1>How to build webapps that scale</h1>\n\n      <div class=\"article-meta\">\n        <a href=\"/profile/eric-simons\"><img src=\"http://i.imgur.com/Qr71crq.jpg\" /></a>\n        <div class=\"info\">\n          <a href=\"/profile/eric-simons\" class=\"author\">Eric Simons</a>\n          <span class=\"date\">January 20th</span>\n        </div>\n        <button class=\"btn btn-sm btn-outline-secondary\">\n          <i class=\"ion-plus-round\"></i>\n          &nbsp; Follow Eric Simons <span class=\"counter\">(10)</span>\n        </button>\n        &nbsp;&nbsp;\n        <button class=\"btn btn-sm btn-outline-primary\">\n          <i class=\"ion-heart\"></i>\n          &nbsp; Favorite Post <span class=\"counter\">(29)</span>\n        </button>\n        <button class=\"btn btn-sm btn-outline-secondary\">\n          <i class=\"ion-edit\"></i> Edit Article\n        </button>\n        <button class=\"btn btn-sm btn-outline-danger\">\n          <i class=\"ion-trash-a\"></i> Delete Article\n        </button>\n      </div>\n    </div>\n  </div>\n\n  <div class=\"container page\">\n    <div class=\"row article-content\">\n      <div class=\"col-md-12\">\n        <p>\n          Web development technologies have evolved at an incredible clip over the past few years.\n        </p>\n        <h2 id=\"introducing-ionic\">Introducing RealWorld.</h2>\n        <p>It's a great solution for learning how other frameworks work.</p>\n        <ul class=\"tag-list\">\n          <li class=\"tag-default tag-pill tag-outline\">realworld</li>\n          <li class=\"tag-default tag-pill tag-outline\">implementations</li>\n        </ul>\n      </div>\n    </div>\n\n    <hr />\n\n    <div class=\"article-actions\">\n      <div class=\"article-meta\">\n        <a href=\"profile.html\"><img src=\"http://i.imgur.com/Qr71crq.jpg\" /></a>\n        <div class=\"info\">\n          <a href=\"\" class=\"author\">Eric Simons</a>\n          <span class=\"date\">January 20th</span>\n        </div>\n\n        <button class=\"btn btn-sm btn-outline-secondary\">\n          <i class=\"ion-plus-round\"></i>\n          &nbsp; Follow Eric Simons\n        </button>\n        &nbsp;\n        <button class=\"btn btn-sm btn-outline-primary\">\n          <i class=\"ion-heart\"></i>\n          &nbsp; Favorite Article <span class=\"counter\">(29)</span>\n        </button>\n        <button class=\"btn btn-sm btn-outline-secondary\">\n          <i class=\"ion-edit\"></i> Edit Article\n        </button>\n        <button class=\"btn btn-sm btn-outline-danger\">\n          <i class=\"ion-trash-a\"></i> Delete Article\n        </button>\n      </div>\n    </div>\n\n    <div class=\"row\">\n      <div class=\"col-xs-12 col-md-8 offset-md-2\">\n        <form class=\"card comment-form\">\n          <div class=\"card-block\">\n            <textarea class=\"form-control\" placeholder=\"Write a comment...\" rows=\"3\"></textarea>\n          </div>\n          <div class=\"card-footer\">\n            <img src=\"http://i.imgur.com/Qr71crq.jpg\" class=\"comment-author-img\" />\n            <button class=\"btn btn-sm btn-primary\">Post Comment</button>\n          </div>\n        </form>\n\n        <div class=\"card\">\n          <div class=\"card-block\">\n            <p class=\"card-text\">\n              With supporting text below as a natural lead-in to additional content.\n            </p>\n          </div>\n          <div class=\"card-footer\">\n            <a href=\"/profile/author\" class=\"comment-author\">\n              <img src=\"http://i.imgur.com/Qr71crq.jpg\" class=\"comment-author-img\" />\n            </a>\n            &nbsp;\n            <a href=\"/profile/jacob-schmidt\" class=\"comment-author\">Jacob Schmidt</a>\n            <span class=\"date-posted\">Dec 29th</span>\n          </div>\n        </div>\n\n        <div class=\"card\">\n          <div class=\"card-block\">\n            <p class=\"card-text\">\n              With supporting text below as a natural lead-in to additional content.\n            </p>\n          </div>\n          <div class=\"card-footer\">\n            <a href=\"/profile/author\" class=\"comment-author\">\n              <img src=\"http://i.imgur.com/Qr71crq.jpg\" class=\"comment-author-img\" />\n            </a>\n            &nbsp;\n            <a href=\"/profile/jacob-schmidt\" class=\"comment-author\">Jacob Schmidt</a>\n            <span class=\"date-posted\">Dec 29th</span>\n            <span class=\"mod-options\">\n              <i class=\"ion-trash-a\"></i>\n            </span>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</div>\n```\n"
  },
  {
    "path": "docs/src/content/docs/specifications/frontend/tests.md",
    "content": "---\ntitle: Tests\n---\n\nInclude _at least_ **one** unit test in your repo to demonstrate how testing works (full testing coverage is _not_ required!)\n\n## E2E Tests\n\nA shared [Playwright E2E test suite](https://github.com/realworld-apps/realworld/tree/main/specs/e2e) is available to validate your frontend implementation. It covers authentication, articles, comments, navigation, settings, social features, error handling, and even basic XSS security.\n\nTo make your implementation compatible with the E2E tests, it **must** follow the [selectors contract](https://github.com/realworld-apps/realworld/blob/main/specs/e2e/SELECTORS.md), which defines:\n\n- Form input `name` attributes\n- Required CSS classes (layout, feed, tags, comments, profile, pagination, buttons, errors)\n- Required text content for buttons and links\n- Routes\n- A debug interface (`window.__conduit_debug__`)\n- LocalStorage key for the JWT token\n- Default avatar behavior\n\n### Running the tests\n\nThe test suite ships with a [base Playwright config](https://github.com/realworld-apps/realworld/blob/main/specs/e2e/playwright.base.ts) that you can extend in your implementation. Override `baseURL` and `webServer` to point to your dev server.\n\nSee the [Angular implementation](https://github.com/realworld-apps/angular-realworld-example-app) for a working example.\n"
  },
  {
    "path": "docs/src/content/docs/specifications/mobile-specs/introduction.md",
    "content": "---\ntitle: Introduction\n---\n\n### [Icons for (iOS/Android)](https://github.com/realworld-apps/realworld/tree/master/spec/mobile_icons)\n\n### Styles/Templates\n\nUnfortunately, there isn't a common way for us to reuse & share styles/templates for cross-platform mobile apps.\n\nInstead, we recommend using the Medium.com [iOS](https://itunes.apple.com/us/app/medium/id828256236?mt=8) and [Android](https://play.google.com/store/apps/details?id=com.medium.reader&hl=en) apps as a \"north star\" regarding general UI functionality/layout, but try not to go too overboard otherwise it will unnecessarily complicate your codebase (in other words, [KISS](https://en.wikipedia.org/wiki/KISS_principle) :)\n"
  },
  {
    "path": "docs/src/env.d.ts",
    "content": "/// <reference path=\"../.astro/types.d.ts\" />\n/// <reference types=\"astro/client\" />\n"
  },
  {
    "path": "docs/src/tailwind.css",
    "content": "@import \"tailwindcss\";\n@import \"@astrojs/starlight-tailwind\";\n@source \"../**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}\";\n\n@theme {\n    --color-accent-200: #e3b6ed;\n    --color-accent-600: #a700c3;\n    --color-accent-900: #4e0e5b;\n    --color-accent-950: #36113e;\n    --color-gray-100: #f8f4fe;\n    --color-gray-200: #f2e9fd;\n    --color-gray-300: #c7bdd5;\n    --color-gray-400: #9581ae;\n    --color-gray-500: #614e78;\n    --color-gray-700: #412e55;\n    --color-gray-800: #2f1c42;\n    --color-gray-900: #1c1425;\n}\n\n/* Restore list styles in content area */\n.sl-markdown-content ul {\n    list-style-type: disc;\n    padding-left: 1.5rem;\n}\n\n.sl-markdown-content ol {\n    list-style-type: decimal;\n    padding-left: 1.5rem;\n}\n\n.sl-markdown-content ul ul,\n.sl-markdown-content ol ul {\n    list-style-type: circle;\n}\n\n.sl-markdown-content ul ul ul,\n.sl-markdown-content ol ol ul,\n.sl-markdown-content ol ul ul {\n    list-style-type: square;\n}\n"
  },
  {
    "path": "docs/tsconfig.json",
    "content": "{\n  \"extends\": \"astro/tsconfigs/strict\",\n  \"compilerOptions\": {\n    \"jsx\": \"react-jsx\",\n    \"jsxImportSource\": \"react\"\n  }\n}"
  },
  {
    "path": "specs/api/README.md",
    "content": "# RealWorld API Spec\n\n## Running API tests locally\n\n### With Hurl\n\nTo locally run the provided [Hurl](https://hurl.dev) collection against your backend, execute:\n\n```\nHOST=http://localhost:3000/api ./run-api-tests-hurl.sh\n```\n\nFor more details, see [`run-api-tests-hurl.sh`](run-api-tests-hurl.sh).\n\n### With Bruno\n\nA [Bruno](https://www.usebruno.com) collection is also available, automatically generated from the Hurl test suite. To run it:\n\n```\nHOST=http://localhost:3000/api ./run-api-tests-bruno.sh\n```\n\nFor more details, see [`run-api-tests-bruno.sh`](run-api-tests-bruno.sh).\n\nYou can also open the `bruno/` folder directly in the Bruno app to run and inspect requests interactively.\n\n> **Note:** The Hurl files are the source of truth. The Bruno collection is generated with `make bruno-generate` and kept in sync via CI (`make bruno-check`).\n"
  },
  {
    "path": "specs/api/bruno/articles/01-setup-register.bru",
    "content": "meta {\n  name: Setup: Register\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"art_{{uid}}\",\n      \"email\": \"art_{{uid}}@test.com\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"token\", res.body.user.token);\n}\n"
  },
  {
    "path": "specs/api/bruno/articles/02-create-article-with-tags.bru",
    "content": "meta {\n  name: Create article with tags\n  type: http\n  seq: 2\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"title\": \"Test Article {{uid}}\",\n      \"description\": \"Test description\",\n      \"body\": \"Test body content\",\n      \"tagList\": [\"d_{{uid}}\", \"t_{{uid}}\"]\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"slug\", res.body.article.slug);\n  bru.setVar(\"created_at\", res.body.article.createdAt);\n  bru.setVar(\"updated_at\", res.body.article.updatedAt);\n  expect(res.body.article.title).to.eql(\"Test Article \" + bru.getVar(\"uid\"));\n  expect(typeof res.body.article.slug).to.eql(\"string\");\n  expect(res.body.article.description).to.eql(\"Test description\");\n  expect(res.body.article.body).to.eql(\"Test body content\");\n  expect(res.body.article.tagList).to.include(\"d_\" + bru.getVar(\"uid\"));\n  expect(res.body.article.tagList).to.include(\"t_\" + bru.getVar(\"uid\"));\n  expect(res.body.article.tagList[0]).to.eql(\"d_\" + bru.getVar(\"uid\"));\n  expect(res.body.article.tagList[1]).to.eql(\"t_\" + bru.getVar(\"uid\"));\n  expect(res.body.article.createdAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T/);\n  expect(res.body.article.updatedAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T/);\n  expect(res.body.article.favorited).to.eql(false);\n  expect(res.body.article.favoritesCount).to.eql(0);\n  expect(res.body.article.author.username).to.eql(\"art_\" + bru.getVar(\"uid\"));\n}\n"
  },
  {
    "path": "specs/api/bruno/articles/03-list-all-articles.bru",
    "content": "meta {\n  name: List all articles\n  type: http\n  seq: 3\n}\n\nget {\n  url: {{host}}/api/articles\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(Array.isArray(res.body.articles)).to.eql(true);\n  expect(Number.isInteger(res.body.articlesCount)).to.eql(true);\n  expect(res.body.articlesCount).to.be.at.least(1);\n  expect(typeof res.body.articles[0].title).to.eql(\"string\");\n  expect(typeof res.body.articles[0].slug).to.eql(\"string\");\n  expect(typeof res.body.articles[0].description).to.eql(\"string\");\n  expect(res.body.articles[0]).to.not.have.property(\"body\");\n  expect(Array.isArray(res.body.articles[0].tagList)).to.eql(true);\n  expect(res.body.articles[0].createdAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.articles[0].updatedAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(typeof res.body.articles[0].favorited).to.eql(\"boolean\");\n  expect(Number.isInteger(res.body.articles[0].favoritesCount)).to.eql(true);\n  expect(typeof res.body.articles[0].author.username).to.eql(\"string\");\n}\n"
  },
  {
    "path": "specs/api/bruno/articles/04-list-by-author.bru",
    "content": "meta {\n  name: List by author\n  type: http\n  seq: 4\n}\n\nget {\n  url: {{host}}/api/articles?author=art_{{uid}}\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(Array.isArray(res.body.articles)).to.eql(true);\n  expect(Number.isInteger(res.body.articlesCount)).to.eql(true);\n  expect(res.body.articlesCount).to.be.at.least(1);\n  expect(typeof res.body.articles[0].title).to.eql(\"string\");\n  expect(typeof res.body.articles[0].slug).to.eql(\"string\");\n  expect(typeof res.body.articles[0].description).to.eql(\"string\");\n  expect(res.body.articles[0]).to.not.have.property(\"body\");\n  expect(Array.isArray(res.body.articles[0].tagList)).to.eql(true);\n  expect(res.body.articles[0].createdAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.articles[0].updatedAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(typeof res.body.articles[0].favorited).to.eql(\"boolean\");\n  expect(Number.isInteger(res.body.articles[0].favoritesCount)).to.eql(true);\n  expect(res.body.articles[0].author.username).to.eql(\"art_\" + bru.getVar(\"uid\"));\n}\n"
  },
  {
    "path": "specs/api/bruno/articles/05-list-all-articles-with-auth.bru",
    "content": "meta {\n  name: List all articles with auth\n  type: http\n  seq: 5\n}\n\nget {\n  url: {{host}}/api/articles\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(Array.isArray(res.body.articles)).to.eql(true);\n  expect(Number.isInteger(res.body.articlesCount)).to.eql(true);\n  expect(res.body.articlesCount).to.be.at.least(1);\n  expect(typeof res.body.articles[0].title).to.eql(\"string\");\n  expect(typeof res.body.articles[0].slug).to.eql(\"string\");\n  expect(typeof res.body.articles[0].description).to.eql(\"string\");\n  expect(res.body.articles[0]).to.not.have.property(\"body\");\n  expect(Array.isArray(res.body.articles[0].tagList)).to.eql(true);\n  expect(res.body.articles[0].createdAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.articles[0].updatedAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(typeof res.body.articles[0].favorited).to.eql(\"boolean\");\n  expect(Number.isInteger(res.body.articles[0].favoritesCount)).to.eql(true);\n  expect(typeof res.body.articles[0].author.username).to.eql(\"string\");\n}\n"
  },
  {
    "path": "specs/api/bruno/articles/06-list-by-author-with-auth.bru",
    "content": "meta {\n  name: List by author with auth\n  type: http\n  seq: 6\n}\n\nget {\n  url: {{host}}/api/articles?author=art_{{uid}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(Array.isArray(res.body.articles)).to.eql(true);\n  expect(Number.isInteger(res.body.articlesCount)).to.eql(true);\n  expect(res.body.articlesCount).to.be.at.least(1);\n  expect(typeof res.body.articles[0].title).to.eql(\"string\");\n  expect(typeof res.body.articles[0].slug).to.eql(\"string\");\n  expect(typeof res.body.articles[0].description).to.eql(\"string\");\n  expect(res.body.articles[0]).to.not.have.property(\"body\");\n  expect(Array.isArray(res.body.articles[0].tagList)).to.eql(true);\n  expect(res.body.articles[0].createdAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.articles[0].updatedAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(typeof res.body.articles[0].favorited).to.eql(\"boolean\");\n  expect(Number.isInteger(res.body.articles[0].favoritesCount)).to.eql(true);\n  expect(res.body.articles[0].author.username).to.eql(\"art_\" + bru.getVar(\"uid\"));\n}\n"
  },
  {
    "path": "specs/api/bruno/articles/07-list-by-tag.bru",
    "content": "meta {\n  name: List by tag\n  type: http\n  seq: 7\n}\n\nget {\n  url: {{host}}/api/articles?tag=d_{{uid}}\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(Array.isArray(res.body.articles)).to.eql(true);\n  expect(Number.isInteger(res.body.articlesCount)).to.eql(true);\n  expect(res.body.articlesCount).to.be.at.least(1);\n  expect(typeof res.body.articles[0].title).to.eql(\"string\");\n  expect(typeof res.body.articles[0].slug).to.eql(\"string\");\n  expect(typeof res.body.articles[0].description).to.eql(\"string\");\n  expect(res.body.articles[0]).to.not.have.property(\"body\");\n  expect(Array.isArray(res.body.articles[0].tagList)).to.eql(true);\n  expect(res.body.articles[0].tagList).to.include(\"d_\" + bru.getVar(\"uid\"));\n  expect(res.body.articles[0].createdAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.articles[0].updatedAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(typeof res.body.articles[0].favorited).to.eql(\"boolean\");\n  expect(Number.isInteger(res.body.articles[0].favoritesCount)).to.eql(true);\n  expect(typeof res.body.articles[0].author.username).to.eql(\"string\");\n}\n"
  },
  {
    "path": "specs/api/bruno/articles/08-list-articles-without-auth.bru",
    "content": "meta {\n  name: List articles without auth\n  type: http\n  seq: 8\n}\n\nget {\n  url: {{host}}/api/articles\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(Array.isArray(res.body.articles)).to.eql(true);\n  expect(Number.isInteger(res.body.articlesCount)).to.eql(true);\n}\n"
  },
  {
    "path": "specs/api/bruno/articles/09-get-single-article.bru",
    "content": "meta {\n  name: Get single article\n  type: http\n  seq: 9\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.article.title).to.eql(\"Test Article \" + bru.getVar(\"uid\"));\n  expect(res.body.article.slug).to.eql(bru.getVar(\"slug\"));\n  expect(res.body.article.description).to.eql(\"Test description\");\n  expect(res.body.article.body).to.eql(\"Test body content\");\n  expect(Array.isArray(res.body.article.tagList)).to.eql(true);\n  expect(res.body.article.createdAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.article.updatedAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.article.favorited).to.eql(false);\n  expect(res.body.article.favoritesCount).to.eql(0);\n  expect(res.body.article.author.username).to.eql(\"art_\" + bru.getVar(\"uid\"));\n}\n"
  },
  {
    "path": "specs/api/bruno/articles/10-update-article-body.bru",
    "content": "meta {\n  name: Update article body\n  type: http\n  seq: 10\n}\n\nput {\n  url: {{host}}/api/articles/{{slug}}\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"body\": \"Updated body content\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.article.title).to.eql(\"Test Article \" + bru.getVar(\"uid\"));\n  expect(res.body.article.slug).to.eql(bru.getVar(\"slug\"));\n  expect(res.body.article.description).to.eql(\"Test description\");\n  expect(res.body.article.body).to.eql(\"Updated body content\");\n  expect(Array.isArray(res.body.article.tagList)).to.eql(true);\n  expect(res.body.article.tagList.length).to.eql(2);\n  expect(res.body.article.tagList).to.include(\"d_\" + bru.getVar(\"uid\"));\n  expect(res.body.article.tagList).to.include(\"t_\" + bru.getVar(\"uid\"));\n  expect(res.body.article.createdAt).to.eql(bru.getVar(\"created_at\"));\n  expect(res.body.article.updatedAt).to.not.eql(bru.getVar(\"updated_at\"));\n  expect(typeof res.body.article.favorited).to.eql(\"boolean\");\n  expect(Number.isInteger(res.body.article.favoritesCount)).to.eql(true);\n  expect(res.body.article.author.username).to.eql(\"art_\" + bru.getVar(\"uid\"));\n}\n"
  },
  {
    "path": "specs/api/bruno/articles/11-verify-update-persisted.bru",
    "content": "meta {\n  name: Verify update persisted\n  type: http\n  seq: 11\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.article.title).to.eql(\"Test Article \" + bru.getVar(\"uid\"));\n  expect(res.body.article.slug).to.eql(bru.getVar(\"slug\"));\n  expect(res.body.article.description).to.eql(\"Test description\");\n  expect(res.body.article.body).to.eql(\"Updated body content\");\n  expect(Array.isArray(res.body.article.tagList)).to.eql(true);\n  expect(res.body.article.tagList.length).to.eql(2);\n  expect(res.body.article.tagList).to.include(\"d_\" + bru.getVar(\"uid\"));\n  expect(res.body.article.tagList).to.include(\"t_\" + bru.getVar(\"uid\"));\n  expect(res.body.article.createdAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.article.updatedAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(typeof res.body.article.favorited).to.eql(\"boolean\");\n  expect(Number.isInteger(res.body.article.favoritesCount)).to.eql(true);\n  expect(res.body.article.author.username).to.eql(\"art_\" + bru.getVar(\"uid\"));\n}\n"
  },
  {
    "path": "specs/api/bruno/articles/12-update-article-without-taglist-tags-should-be-preserved.bru",
    "content": "meta {\n  name: Update article without tagList: tags should be preserved\n  type: http\n  seq: 12\n}\n\nput {\n  url: {{host}}/api/articles/{{slug}}\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"body\": \"Body without touching tags\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.article.body).to.eql(\"Body without touching tags\");\n  expect(res.body.article.tagList.length).to.eql(2);\n  expect(res.body.article.tagList).to.include(\"d_\" + bru.getVar(\"uid\"));\n  expect(res.body.article.tagList).to.include(\"t_\" + bru.getVar(\"uid\"));\n}\n"
  },
  {
    "path": "specs/api/bruno/articles/13-update-article-remove-all-tags-with-empty-array.bru",
    "content": "meta {\n  name: Update article: remove all tags with empty array\n  type: http\n  seq: 13\n}\n\nput {\n  url: {{host}}/api/articles/{{slug}}\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"tagList\": []\n    }\n  }\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(Array.isArray(res.body.article.tagList)).to.eql(true);\n  expect(res.body.article.tagList.length).to.eql(0);\n}\n"
  },
  {
    "path": "specs/api/bruno/articles/14-verify-tags-were-actually-removed.bru",
    "content": "meta {\n  name: Verify tags were actually removed\n  type: http\n  seq: 14\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(Array.isArray(res.body.article.tagList)).to.eql(true);\n  expect(res.body.article.tagList.length).to.eql(0);\n}\n"
  },
  {
    "path": "specs/api/bruno/articles/15-update-article-taglist-null-should-be-rejected.bru",
    "content": "meta {\n  name: Update article: tagList null should be rejected\n  type: http\n  seq: 15\n}\n\nput {\n  url: {{host}}/api/articles/{{slug}}\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"tagList\": null\n    }\n  }\n}\n\nassert {\n  res.status: eq 422\n}\n"
  },
  {
    "path": "specs/api/bruno/articles/16-delete-article.bru",
    "content": "meta {\n  name: Delete article\n  type: http\n  seq: 16\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 204\n}\n"
  },
  {
    "path": "specs/api/bruno/articles/17-verify-deletion.bru",
    "content": "meta {\n  name: Verify deletion\n  type: http\n  seq: 17\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 404\n}\n\nscript:post-response {\n  expect(res.body.errors.article[0]).to.eql(\"not found\");\n}\n"
  },
  {
    "path": "specs/api/bruno/auth/01-register.bru",
    "content": "meta {\n  name: Register\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"auth_{{uid}}\",\n      \"email\": \"auth_{{uid}}@test.com\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"reg_token\", res.body.user.token);\n  expect(res.body.user.username).to.eql(\"auth_\" + bru.getVar(\"uid\"));\n  expect(res.body.user.email).to.eql(\"auth_\" + bru.getVar(\"uid\") + \"@test.com\");\n  expect(res.body.user.bio).to.be.null;\n  expect(res.body.user.image).to.be.null;\n  expect(typeof res.body.user.token).to.eql(\"string\");\n  expect(res.body.user.token).to.not.eql(\"\");\n}\n"
  },
  {
    "path": "specs/api/bruno/auth/02-login.bru",
    "content": "meta {\n  name: Login\n  type: http\n  seq: 2\n}\n\npost {\n  url: {{host}}/api/users/login\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"email\": \"auth_{{uid}}@test.com\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  bru.setVar(\"token\", res.body.user.token);\n  expect(res.body.user.username).to.eql(\"auth_\" + bru.getVar(\"uid\"));\n  expect(res.body.user.email).to.eql(\"auth_\" + bru.getVar(\"uid\") + \"@test.com\");\n  expect(res.body.user.bio).to.be.null;\n  expect(res.body.user.image).to.be.null;\n  expect(typeof res.body.user.token).to.eql(\"string\");\n  expect(res.body.user.token).to.not.eql(\"\");\n}\n"
  },
  {
    "path": "specs/api/bruno/auth/03-get-current-user.bru",
    "content": "meta {\n  name: Get current user\n  type: http\n  seq: 3\n}\n\nget {\n  url: {{host}}/api/user\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.user.username).to.eql(\"auth_\" + bru.getVar(\"uid\"));\n  expect(res.body.user.email).to.eql(\"auth_\" + bru.getVar(\"uid\") + \"@test.com\");\n  expect(res.body.user.bio).to.be.null;\n  expect(res.body.user.image).to.be.null;\n  expect(typeof res.body.user.token).to.eql(\"string\");\n  expect(res.body.user.token).to.not.eql(\"\");\n}\n"
  },
  {
    "path": "specs/api/bruno/auth/04-update-user.bru",
    "content": "meta {\n  name: Update user\n  type: http\n  seq: 4\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"bio\": \"Updated bio\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.user.username).to.eql(\"auth_\" + bru.getVar(\"uid\"));\n  expect(res.body.user.email).to.eql(\"auth_\" + bru.getVar(\"uid\") + \"@test.com\");\n  expect(res.body.user.bio).to.eql(\"Updated bio\");\n  expect(res.body.user.image).to.be.null;\n  expect(typeof res.body.user.token).to.eql(\"string\");\n  expect(res.body.user.token).to.not.eql(\"\");\n}\n"
  },
  {
    "path": "specs/api/bruno/auth/05-verify-update-persisted.bru",
    "content": "meta {\n  name: Verify update persisted\n  type: http\n  seq: 5\n}\n\nget {\n  url: {{host}}/api/user\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.user.username).to.eql(\"auth_\" + bru.getVar(\"uid\"));\n  expect(res.body.user.email).to.eql(\"auth_\" + bru.getVar(\"uid\") + \"@test.com\");\n  expect(res.body.user.bio).to.eql(\"Updated bio\");\n  expect(res.body.user.image).to.be.null;\n  expect(typeof res.body.user.token).to.eql(\"string\");\n  expect(res.body.user.token).to.not.eql(\"\");\n}\n"
  },
  {
    "path": "specs/api/bruno/auth/06-update-user-bio-to-empty-string-should-normalize-to-null.bru",
    "content": "meta {\n  name: Update user bio to empty string - should normalize to null\n  type: http\n  seq: 6\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"bio\": \"\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.user.bio).to.be.null;\n}\n"
  },
  {
    "path": "specs/api/bruno/auth/07-verify-empty-string-normalization-persisted.bru",
    "content": "meta {\n  name: Verify empty string normalization persisted\n  type: http\n  seq: 7\n}\n\nget {\n  url: {{host}}/api/user\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.user.bio).to.be.null;\n}\n"
  },
  {
    "path": "specs/api/bruno/auth/08-restore-bio-then-set-to-null.bru",
    "content": "meta {\n  name: Restore bio then set to null\n  type: http\n  seq: 8\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"bio\": \"Temporary bio\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.user.bio).to.eql(\"Temporary bio\");\n}\n"
  },
  {
    "path": "specs/api/bruno/auth/09-update-user-bio-to-null-should-accept-for-nullable-field.bru",
    "content": "meta {\n  name: Update user bio to null - should accept for nullable field\n  type: http\n  seq: 9\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"bio\": null\n    }\n  }\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.user.bio).to.be.null;\n}\n"
  },
  {
    "path": "specs/api/bruno/auth/10-verify-null-bio-persisted.bru",
    "content": "meta {\n  name: Verify null bio persisted\n  type: http\n  seq: 10\n}\n\nget {\n  url: {{host}}/api/user\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.user.bio).to.be.null;\n}\n"
  },
  {
    "path": "specs/api/bruno/auth/11-restore-bio.bru",
    "content": "meta {\n  name: Restore bio\n  type: http\n  seq: 11\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"bio\": \"Updated bio\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.user.username).to.eql(\"auth_\" + bru.getVar(\"uid\"));\n  expect(res.body.user.email).to.eql(\"auth_\" + bru.getVar(\"uid\") + \"@test.com\");\n  expect(res.body.user.bio).to.eql(\"Updated bio\");\n  expect(res.body.user.image).to.be.null;\n  expect(typeof res.body.user.token).to.eql(\"string\");\n  expect(res.body.user.token).to.not.eql(\"\");\n}\n"
  },
  {
    "path": "specs/api/bruno/auth/12-update-user-image.bru",
    "content": "meta {\n  name: Update user image\n  type: http\n  seq: 12\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"image\": \"https://example.com/photo.jpg\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.user.image).to.eql(\"https://example.com/photo.jpg\");\n}\n"
  },
  {
    "path": "specs/api/bruno/auth/13-verify-image-update-persisted.bru",
    "content": "meta {\n  name: Verify image update persisted\n  type: http\n  seq: 13\n}\n\nget {\n  url: {{host}}/api/user\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.user.image).to.eql(\"https://example.com/photo.jpg\");\n}\n"
  },
  {
    "path": "specs/api/bruno/auth/14-update-image-to-empty-string-should-normalize-to-null.bru",
    "content": "meta {\n  name: Update image to empty string - should normalize to null\n  type: http\n  seq: 14\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"image\": \"\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.user.image).to.be.null;\n}\n"
  },
  {
    "path": "specs/api/bruno/auth/15-verify-image-empty-string-normalization-persisted.bru",
    "content": "meta {\n  name: Verify image empty string normalization persisted\n  type: http\n  seq: 15\n}\n\nget {\n  url: {{host}}/api/user\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.user.image).to.be.null;\n}\n"
  },
  {
    "path": "specs/api/bruno/auth/16-set-image-then-update-to-null-should-accept-for-nullable-field.bru",
    "content": "meta {\n  name: Set image then update to null - should accept for nullable field\n  type: http\n  seq: 16\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"image\": \"https://example.com/temp.jpg\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.user.image).to.eql(\"https://example.com/temp.jpg\");\n}\n"
  },
  {
    "path": "specs/api/bruno/auth/17-put-user.bru",
    "content": "meta {\n  name: PUT user\n  type: http\n  seq: 17\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"image\": null\n    }\n  }\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.user.image).to.be.null;\n}\n"
  },
  {
    "path": "specs/api/bruno/auth/18-verify-null-image-persisted.bru",
    "content": "meta {\n  name: Verify null image persisted\n  type: http\n  seq: 18\n}\n\nget {\n  url: {{host}}/api/user\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.user.image).to.be.null;\n}\n"
  },
  {
    "path": "specs/api/bruno/auth/19-update-username-and-email.bru",
    "content": "meta {\n  name: Update username and email\n  type: http\n  seq: 19\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"auth_{{uid}}_upd\",\n      \"email\": \"auth_{{uid}}_upd@test.com\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  bru.setVar(\"updated_token\", res.body.user.token);\n  expect(res.body.user.username).to.eql(\"auth_\" + bru.getVar(\"uid\") + \"_upd\");\n  expect(res.body.user.email).to.eql(\"auth_\" + bru.getVar(\"uid\") + \"_upd@test.com\");\n  expect(res.body.user.bio).to.eql(\"Updated bio\");\n  expect(res.body.user.image).to.be.null;\n  expect(typeof res.body.user.token).to.eql(\"string\");\n  expect(res.body.user.token).to.not.eql(\"\");\n}\n"
  },
  {
    "path": "specs/api/bruno/auth/20-verify-username-email-update-persisted.bru",
    "content": "meta {\n  name: Verify username/email update persisted\n  type: http\n  seq: 20\n}\n\nget {\n  url: {{host}}/api/user\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{updated_token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.user.username).to.eql(\"auth_\" + bru.getVar(\"uid\") + \"_upd\");\n  expect(res.body.user.email).to.eql(\"auth_\" + bru.getVar(\"uid\") + \"_upd@test.com\");\n  expect(res.body.user.bio).to.eql(\"Updated bio\");\n  expect(res.body.user.image).to.be.null;\n  expect(typeof res.body.user.token).to.eql(\"string\");\n  expect(res.body.user.token).to.not.eql(\"\");\n}\n"
  },
  {
    "path": "specs/api/bruno/bruno.json",
    "content": "{\n  \"version\": \"1\",\n  \"name\": \"RealWorld API\",\n  \"type\": \"collection\"\n}\n"
  },
  {
    "path": "specs/api/bruno/collection.bru",
    "content": "script:pre-request {\n  if (!bru.getVar(\"uid\")) {\n    bru.setVar(\"uid\", Date.now().toString() + Math.random().toString(36).substring(2, 6));\n  }\n}\n"
  },
  {
    "path": "specs/api/bruno/comments/01-setup-register.bru",
    "content": "meta {\n  name: Setup: Register\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"cmt_{{uid}}\",\n      \"email\": \"cmt_{{uid}}@test.com\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"token\", res.body.user.token);\n}\n"
  },
  {
    "path": "specs/api/bruno/comments/02-setup-create-article.bru",
    "content": "meta {\n  name: Setup: Create article\n  type: http\n  seq: 2\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"title\": \"Comment Article {{uid}}\",\n      \"description\": \"For comments\",\n      \"body\": \"Article body\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"slug\", res.body.article.slug);\n}\n"
  },
  {
    "path": "specs/api/bruno/comments/03-create-comment.bru",
    "content": "meta {\n  name: Create comment\n  type: http\n  seq: 3\n}\n\npost {\n  url: {{host}}/api/articles/{{slug}}/comments\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"comment\": {\n      \"body\": \"Test comment body\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"comment_id\", res.body.comment.id);\n  expect(Number.isInteger(res.body.comment.id)).to.eql(true);\n  expect(res.body.comment.body).to.eql(\"Test comment body\");\n  expect(res.body.comment.createdAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.comment.updatedAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.comment.author.username).to.eql(\"cmt_\" + bru.getVar(\"uid\"));\n}\n"
  },
  {
    "path": "specs/api/bruno/comments/04-list-comments.bru",
    "content": "meta {\n  name: List comments\n  type: http\n  seq: 4\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}/comments\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(Array.isArray(res.body.comments)).to.eql(true);\n  expect(res.body.comments.length).to.eql(1);\n  expect(res.body.comments[0].id).to.eql(bru.getVar(\"comment_id\"));\n  expect(res.body.comments[0].body).to.eql(\"Test comment body\");\n  expect(res.body.comments[0].createdAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.comments[0].updatedAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.comments[0].author.username).to.eql(\"cmt_\" + bru.getVar(\"uid\"));\n}\n"
  },
  {
    "path": "specs/api/bruno/comments/05-list-comments-without-auth.bru",
    "content": "meta {\n  name: List comments without auth\n  type: http\n  seq: 5\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}/comments\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(Array.isArray(res.body.comments)).to.eql(true);\n  expect(res.body.comments.length).to.eql(1);\n  expect(Number.isInteger(res.body.comments[0].id)).to.eql(true);\n  expect(res.body.comments[0].body).to.eql(\"Test comment body\");\n  expect(res.body.comments[0].createdAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.comments[0].updatedAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.comments[0].author.username).to.eql(\"cmt_\" + bru.getVar(\"uid\"));\n}\n"
  },
  {
    "path": "specs/api/bruno/comments/06-delete-comment.bru",
    "content": "meta {\n  name: Delete comment\n  type: http\n  seq: 6\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug}}/comments/{{comment_id}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 204\n}\n"
  },
  {
    "path": "specs/api/bruno/comments/07-verify-deletion.bru",
    "content": "meta {\n  name: Verify deletion\n  type: http\n  seq: 7\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}/comments\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.comments.length).to.eql(0);\n}\n"
  },
  {
    "path": "specs/api/bruno/comments/08-selective-deletion-create-two-comments-delete-one-verify-the-other-remains.bru",
    "content": "meta {\n  name: Selective deletion: create two comments, delete one, verify the other remains\n  type: http\n  seq: 8\n}\n\npost {\n  url: {{host}}/api/articles/{{slug}}/comments\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"comment\": {\n      \"body\": \"First comment\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"first_comment_id\", res.body.comment.id);\n}\n"
  },
  {
    "path": "specs/api/bruno/comments/09-post-comments.bru",
    "content": "meta {\n  name: POST comments\n  type: http\n  seq: 9\n}\n\npost {\n  url: {{host}}/api/articles/{{slug}}/comments\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"comment\": {\n      \"body\": \"Second comment\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n"
  },
  {
    "path": "specs/api/bruno/comments/10-verify-two-comments-exist.bru",
    "content": "meta {\n  name: Verify two comments exist\n  type: http\n  seq: 10\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}/comments\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.comments.length).to.eql(2);\n}\n"
  },
  {
    "path": "specs/api/bruno/comments/11-delete-the-first-comment.bru",
    "content": "meta {\n  name: Delete the first comment\n  type: http\n  seq: 11\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug}}/comments/{{first_comment_id}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 204\n}\n"
  },
  {
    "path": "specs/api/bruno/comments/12-verify-only-the-second-comment-remains.bru",
    "content": "meta {\n  name: Verify only the second comment remains\n  type: http\n  seq: 12\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}/comments\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.comments.length).to.eql(1);\n  expect(res.body.comments[0].body).to.eql(\"Second comment\");\n}\n"
  },
  {
    "path": "specs/api/bruno/comments/13-cleanup.bru",
    "content": "meta {\n  name: Cleanup\n  type: http\n  seq: 13\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 204\n}\n"
  },
  {
    "path": "specs/api/bruno/environments/local.bru",
    "content": "vars {\n  host: http://localhost:3000\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/01-create-article-no-auth.bru",
    "content": "meta {\n  name: Create article no auth\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"title\": \"No Auth Article\",\n      \"description\": \"test\",\n      \"body\": \"test\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 401\n}\n\nscript:post-response {\n  expect(res.body.errors.token[0]).to.eql(\"is missing\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/02-get-unknown-slug.bru",
    "content": "meta {\n  name: GET unknown slug\n  type: http\n  seq: 2\n}\n\nget {\n  url: {{host}}/api/articles/unknown-slug-{{uid}}\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 404\n}\n\nscript:post-response {\n  expect(res.body.errors.article[0]).to.eql(\"not found\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/03-update-no-auth.bru",
    "content": "meta {\n  name: Update no auth\n  type: http\n  seq: 3\n}\n\nput {\n  url: {{host}}/api/articles/some-slug\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"body\": \"test\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 401\n}\n\nscript:post-response {\n  expect(res.body.errors.token[0]).to.eql(\"is missing\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/04-delete-no-auth.bru",
    "content": "meta {\n  name: Delete no auth\n  type: http\n  seq: 4\n}\n\ndelete {\n  url: {{host}}/api/articles/some-slug\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 401\n}\n\nscript:post-response {\n  expect(res.body.errors.token[0]).to.eql(\"is missing\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/05-get-feed-no-auth.bru",
    "content": "meta {\n  name: GET feed no auth\n  type: http\n  seq: 5\n}\n\nget {\n  url: {{host}}/api/articles/feed\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 401\n}\n\nscript:post-response {\n  expect(res.body.errors.token[0]).to.eql(\"is missing\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/06-favorite-no-auth.bru",
    "content": "meta {\n  name: Favorite no auth\n  type: http\n  seq: 6\n}\n\npost {\n  url: {{host}}/api/articles/some-slug/favorite\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 401\n}\n\nscript:post-response {\n  expect(res.body.errors.token[0]).to.eql(\"is missing\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/07-unfavorite-no-auth.bru",
    "content": "meta {\n  name: Unfavorite no auth\n  type: http\n  seq: 7\n}\n\ndelete {\n  url: {{host}}/api/articles/some-slug/favorite\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 401\n}\n\nscript:post-response {\n  expect(res.body.errors.token[0]).to.eql(\"is missing\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/08-setup-register-for-authenticated-error-tests.bru",
    "content": "meta {\n  name: Setup: Register for authenticated error tests\n  type: http\n  seq: 8\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"ea_art_{{uid}}\",\n      \"email\": \"ea_art_{{uid}}@test.com\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"token\", res.body.user.token);\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/09-create-article-empty-title.bru",
    "content": "meta {\n  name: Create article empty title\n  type: http\n  seq: 9\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"title\": \"\",\n      \"description\": \"test\",\n      \"body\": \"test\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 422\n}\n\nscript:post-response {\n  expect(res.body.errors.title[0]).to.eql(\"can't be blank\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/10-create-article-empty-description.bru",
    "content": "meta {\n  name: Create article empty description\n  type: http\n  seq: 10\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"title\": \"Err Desc {{uid}}\",\n      \"description\": \"\",\n      \"body\": \"test\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 422\n}\n\nscript:post-response {\n  expect(res.body.errors.description[0]).to.eql(\"can't be blank\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/11-create-article-empty-body.bru",
    "content": "meta {\n  name: Create article empty body\n  type: http\n  seq: 11\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"title\": \"Err Body {{uid}}\",\n      \"description\": \"test\",\n      \"body\": \"\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 422\n}\n\nscript:post-response {\n  expect(res.body.errors.body[0]).to.eql(\"can't be blank\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/12-duplicate-titles-are-allowed-each-gets-a-unique-slug.bru",
    "content": "meta {\n  name: Duplicate titles are allowed (each gets a unique slug)\n  type: http\n  seq: 12\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"title\": \"Dup Title {{uid}}\",\n      \"description\": \"first\",\n      \"body\": \"first\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"slug1\", res.body.article.slug);\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/13-post-articles.bru",
    "content": "meta {\n  name: POST articles\n  type: http\n  seq: 13\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"title\": \"Dup Title {{uid}}\",\n      \"description\": \"second\",\n      \"body\": \"second\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"slug2\", res.body.article.slug);\n  expect(res.body.article.slug).to.not.eql(bru.getVar(\"slug1\"));\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/14-update-unknown-slug.bru",
    "content": "meta {\n  name: Update unknown slug\n  type: http\n  seq: 14\n}\n\nput {\n  url: {{host}}/api/articles/unknown-slug-{{uid}}\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"body\": \"test\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 404\n}\n\nscript:post-response {\n  expect(res.body.errors.article[0]).to.eql(\"not found\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/15-favorite-unknown-slug.bru",
    "content": "meta {\n  name: Favorite unknown slug\n  type: http\n  seq: 15\n}\n\npost {\n  url: {{host}}/api/articles/unknown-slug-{{uid}}/favorite\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 404\n}\n\nscript:post-response {\n  expect(res.body.errors.article[0]).to.eql(\"not found\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/16-unfavorite-unknown-slug.bru",
    "content": "meta {\n  name: Unfavorite unknown slug\n  type: http\n  seq: 16\n}\n\ndelete {\n  url: {{host}}/api/articles/unknown-slug-{{uid}}/favorite\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 404\n}\n\nscript:post-response {\n  expect(res.body.errors.article[0]).to.eql(\"not found\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/17-update-unknown-slug.bru",
    "content": "meta {\n  name: Update unknown slug\n  type: http\n  seq: 17\n}\n\nput {\n  url: {{host}}/api/articles/unknown-slug-{{uid}}\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"body\": \"test\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 404\n}\n\nscript:post-response {\n  expect(res.body.errors.article[0]).to.eql(\"not found\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/18-delete-unknown-slug.bru",
    "content": "meta {\n  name: Delete unknown slug\n  type: http\n  seq: 18\n}\n\ndelete {\n  url: {{host}}/api/articles/unknown-slug-{{uid}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 404\n}\n\nscript:post-response {\n  expect(res.body.errors.article[0]).to.eql(\"not found\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/19-cleanup.bru",
    "content": "meta {\n  name: Cleanup\n  type: http\n  seq: 19\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug1}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 204\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-articles/20-delete-slug2.bru",
    "content": "meta {\n  name: DELETE {{slug2}}\n  type: http\n  seq: 20\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug2}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 204\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-auth/01-register-empty-username.bru",
    "content": "meta {\n  name: Register empty username\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"\",\n      \"email\": \"ea_blank_{{uid}}@test.com\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 422\n}\n\nscript:post-response {\n  expect(res.body.errors.username[0]).to.eql(\"can't be blank\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-auth/02-register-empty-email.bru",
    "content": "meta {\n  name: Register empty email\n  type: http\n  seq: 2\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"ea_blank_{{uid}}\",\n      \"email\": \"\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 422\n}\n\nscript:post-response {\n  expect(res.body.errors.email[0]).to.eql(\"can't be blank\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-auth/03-register-empty-password.bru",
    "content": "meta {\n  name: Register empty password\n  type: http\n  seq: 3\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"ea_blankp_{{uid}}\",\n      \"email\": \"ea_blankp_{{uid}}@test.com\",\n      \"password\": \"\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 422\n}\n\nscript:post-response {\n  expect(res.body.errors.password[0]).to.eql(\"can't be blank\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-auth/04-register-valid-user-for-duplicate-and-login-tests.bru",
    "content": "meta {\n  name: Register valid user for duplicate and login tests\n  type: http\n  seq: 4\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"ea_dup_{{uid}}\",\n      \"email\": \"ea_dup_{{uid}}@test.com\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"token\", res.body.user.token);\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-auth/05-register-duplicate-username.bru",
    "content": "meta {\n  name: Register duplicate username\n  type: http\n  seq: 5\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"ea_dup_{{uid}}\",\n      \"email\": \"ea_dup2_{{uid}}@test.com\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 409\n}\n\nscript:post-response {\n  expect(res.body.errors.username[0]).to.eql(\"has already been taken\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-auth/06-register-duplicate-email.bru",
    "content": "meta {\n  name: Register duplicate email\n  type: http\n  seq: 6\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"ea_dup2_{{uid}}\",\n      \"email\": \"ea_dup_{{uid}}@test.com\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 409\n}\n\nscript:post-response {\n  expect(res.body.errors.email[0]).to.eql(\"has already been taken\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-auth/07-login-empty-email.bru",
    "content": "meta {\n  name: Login empty email\n  type: http\n  seq: 7\n}\n\npost {\n  url: {{host}}/api/users/login\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"email\": \"\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 422\n}\n\nscript:post-response {\n  expect(res.body.errors.email[0]).to.eql(\"can't be blank\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-auth/08-login-empty-password.bru",
    "content": "meta {\n  name: Login empty password\n  type: http\n  seq: 8\n}\n\npost {\n  url: {{host}}/api/users/login\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"email\": \"ea_dup_{{uid}}@test.com\",\n      \"password\": \"\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 422\n}\n\nscript:post-response {\n  expect(res.body.errors.password[0]).to.eql(\"can't be blank\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-auth/09-login-wrong-password.bru",
    "content": "meta {\n  name: Login wrong password\n  type: http\n  seq: 9\n}\n\npost {\n  url: {{host}}/api/users/login\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"email\": \"ea_dup_{{uid}}@test.com\",\n      \"password\": \"wrongpassword\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 401\n}\n\nscript:post-response {\n  expect(res.body.errors.credentials[0]).to.eql(\"invalid\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-auth/10-get-user-no-auth.bru",
    "content": "meta {\n  name: GET /user no auth\n  type: http\n  seq: 10\n}\n\nget {\n  url: {{host}}/api/user\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 401\n}\n\nscript:post-response {\n  expect(res.body.errors.token[0]).to.eql(\"is missing\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-auth/11-put-user-no-auth.bru",
    "content": "meta {\n  name: PUT /user no auth\n  type: http\n  seq: 11\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"bio\": \"test\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 401\n}\n\nscript:post-response {\n  expect(res.body.errors.token[0]).to.eql(\"is missing\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-auth/12-update-email-to-empty-string-should-reject.bru",
    "content": "meta {\n  name: Update email to empty string - should reject\n  type: http\n  seq: 12\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"email\": \"\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 422\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-auth/13-update-username-to-empty-string-should-reject.bru",
    "content": "meta {\n  name: Update username to empty string - should reject\n  type: http\n  seq: 13\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 422\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-auth/14-update-email-to-null-should-reject.bru",
    "content": "meta {\n  name: Update email to null - should reject\n  type: http\n  seq: 14\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"email\": null\n    }\n  }\n}\n\nassert {\n  res.status: eq 422\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-auth/15-update-username-to-null-should-reject.bru",
    "content": "meta {\n  name: Update username to null - should reject\n  type: http\n  seq: 15\n}\n\nput {\n  url: {{host}}/api/user\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": null\n    }\n  }\n}\n\nassert {\n  res.status: eq 422\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-authorization/01-register-user-a.bru",
    "content": "meta {\n  name: Register user A\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"authz_a_{{uid}}\",\n      \"email\": \"authz_a_{{uid}}@test.com\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"token_a\", res.body.user.token);\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-authorization/02-register-user-b.bru",
    "content": "meta {\n  name: Register user B\n  type: http\n  seq: 2\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"authz_b_{{uid}}\",\n      \"email\": \"authz_b_{{uid}}@test.com\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"token_b\", res.body.user.token);\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-authorization/03-user-a-creates-article.bru",
    "content": "meta {\n  name: User A creates article\n  type: http\n  seq: 3\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token_a}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"title\": \"Authz Article {{uid}}\",\n      \"description\": \"test\",\n      \"body\": \"test\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"slug\", res.body.article.slug);\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-authorization/04-user-b-tries-to-delete-403.bru",
    "content": "meta {\n  name: User B tries to delete -> 403\n  type: http\n  seq: 4\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token_b}}\n}\n\nassert {\n  res.status: eq 403\n}\n\nscript:post-response {\n  expect(res.body.errors.article[0]).to.eql(\"forbidden\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-authorization/05-user-b-tries-to-update-403.bru",
    "content": "meta {\n  name: User B tries to update -> 403\n  type: http\n  seq: 5\n}\n\nput {\n  url: {{host}}/api/articles/{{slug}}\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token_b}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"body\": \"hijacked\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 403\n}\n\nscript:post-response {\n  expect(res.body.errors.article[0]).to.eql(\"forbidden\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-authorization/06-user-a-creates-a-comment-on-the-article.bru",
    "content": "meta {\n  name: User A creates a comment on the article\n  type: http\n  seq: 6\n}\n\npost {\n  url: {{host}}/api/articles/{{slug}}/comments\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token_a}}\n}\n\nbody:json {\n  {\n    \"comment\": {\n      \"body\": \"A's comment\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"comment_id\", res.body.comment.id);\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-authorization/07-user-b-tries-to-delete-a-s-comment-403.bru",
    "content": "meta {\n  name: User B tries to delete A's comment -> 403\n  type: http\n  seq: 7\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug}}/comments/{{comment_id}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token_b}}\n}\n\nassert {\n  res.status: eq 403\n}\n\nscript:post-response {\n  expect(res.body.errors.comment[0]).to.eql(\"forbidden\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-authorization/08-verify-comment-survived-the-failed-delete.bru",
    "content": "meta {\n  name: Verify comment survived the failed delete\n  type: http\n  seq: 8\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}/comments\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.comments.length).to.be.at.least(1);\n  expect(res.body.comments[0].body).to.eql(\"A's comment\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-authorization/09-cleanup-user-a-deletes-article.bru",
    "content": "meta {\n  name: Cleanup: User A deletes article\n  type: http\n  seq: 9\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token_a}}\n}\n\nassert {\n  res.status: eq 204\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-comments/01-post-comment-no-auth.bru",
    "content": "meta {\n  name: Post comment no auth\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/articles/some-slug/comments\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"comment\": {\n      \"body\": \"test\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 401\n}\n\nscript:post-response {\n  expect(res.body.errors.token[0]).to.eql(\"is missing\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-comments/02-delete-comment-no-auth.bru",
    "content": "meta {\n  name: Delete comment no auth\n  type: http\n  seq: 2\n}\n\ndelete {\n  url: {{host}}/api/articles/some-slug/comments/1\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 401\n}\n\nscript:post-response {\n  expect(res.body.errors.token[0]).to.eql(\"is missing\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-comments/03-setup-register-create-article.bru",
    "content": "meta {\n  name: Setup: Register + create article\n  type: http\n  seq: 3\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"ec_{{uid}}\",\n      \"email\": \"ec_{{uid}}@test.com\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"token\", res.body.user.token);\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-comments/04-post-articles.bru",
    "content": "meta {\n  name: POST articles\n  type: http\n  seq: 4\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"title\": \"Err Comment Art {{uid}}\",\n      \"description\": \"test\",\n      \"body\": \"test\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"slug\", res.body.article.slug);\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-comments/05-post-comment-empty-body.bru",
    "content": "meta {\n  name: Post comment empty body\n  type: http\n  seq: 5\n}\n\npost {\n  url: {{host}}/api/articles/{{slug}}/comments\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"comment\": {\n      \"body\": \"\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 422\n}\n\nscript:post-response {\n  expect(res.body.errors.body[0]).to.eql(\"can't be blank\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-comments/06-post-comment-on-unknown-article.bru",
    "content": "meta {\n  name: Post comment on unknown article\n  type: http\n  seq: 6\n}\n\npost {\n  url: {{host}}/api/articles/unknown-slug-{{uid}}/comments\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"comment\": {\n      \"body\": \"orphan\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 404\n}\n\nscript:post-response {\n  expect(res.body.errors.article[0]).to.eql(\"not found\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-comments/07-get-comments-on-unknown-article.bru",
    "content": "meta {\n  name: Get comments on unknown article\n  type: http\n  seq: 7\n}\n\nget {\n  url: {{host}}/api/articles/unknown-slug-{{uid}}/comments\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 404\n}\n\nscript:post-response {\n  expect(res.body.errors.article[0]).to.eql(\"not found\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-comments/08-delete-comment-on-unknown-article.bru",
    "content": "meta {\n  name: Delete comment on unknown article\n  type: http\n  seq: 8\n}\n\ndelete {\n  url: {{host}}/api/articles/unknown-slug-{{uid}}/comments/99999\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 404\n}\n\nscript:post-response {\n  expect(res.body.errors.article[0]).to.eql(\"not found\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-comments/09-delete-non-existent-comment-on-existing-article.bru",
    "content": "meta {\n  name: Delete non-existent comment on existing article\n  type: http\n  seq: 9\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug}}/comments/99999\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 404\n}\n\nscript:post-response {\n  expect(res.body.errors.comment[0]).to.eql(\"not found\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-comments/10-cleanup.bru",
    "content": "meta {\n  name: Cleanup\n  type: http\n  seq: 10\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 204\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-profiles/01-get-unknown-profile.bru",
    "content": "meta {\n  name: GET unknown profile\n  type: http\n  seq: 1\n}\n\nget {\n  url: {{host}}/api/profiles/unknown-user-{{uid}}\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 404\n}\n\nscript:post-response {\n  expect(res.body.errors.profile[0]).to.eql(\"not found\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-profiles/02-follow-no-auth.bru",
    "content": "meta {\n  name: Follow no auth\n  type: http\n  seq: 2\n}\n\npost {\n  url: {{host}}/api/profiles/unknown-user-{{uid}}/follow\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 401\n}\n\nscript:post-response {\n  expect(res.body.errors.token[0]).to.eql(\"is missing\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-profiles/03-unfollow-no-auth.bru",
    "content": "meta {\n  name: Unfollow no auth\n  type: http\n  seq: 3\n}\n\ndelete {\n  url: {{host}}/api/profiles/unknown-user-{{uid}}/follow\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 401\n}\n\nscript:post-response {\n  expect(res.body.errors.token[0]).to.eql(\"is missing\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-profiles/04-setup-register-for-authenticated-404-tests.bru",
    "content": "meta {\n  name: Setup: Register for authenticated 404 tests\n  type: http\n  seq: 4\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"ep_{{uid}}\",\n      \"email\": \"ep_{{uid}}@test.com\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"token\", res.body.user.token);\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-profiles/05-follow-unknown-user-authed.bru",
    "content": "meta {\n  name: Follow unknown user (authed)\n  type: http\n  seq: 5\n}\n\npost {\n  url: {{host}}/api/profiles/unknown-user-{{uid}}/follow\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 404\n}\n\nscript:post-response {\n  expect(res.body.errors.profile[0]).to.eql(\"not found\");\n}\n"
  },
  {
    "path": "specs/api/bruno/errors-profiles/06-unfollow-unknown-user-authed.bru",
    "content": "meta {\n  name: Unfollow unknown user (authed)\n  type: http\n  seq: 6\n}\n\ndelete {\n  url: {{host}}/api/profiles/unknown-user-{{uid}}/follow\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 404\n}\n\nscript:post-response {\n  expect(res.body.errors.profile[0]).to.eql(\"not found\");\n}\n"
  },
  {
    "path": "specs/api/bruno/favorites/01-setup-register.bru",
    "content": "meta {\n  name: Setup: Register\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"fav_{{uid}}\",\n      \"email\": \"fav_{{uid}}@test.com\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"token\", res.body.user.token);\n}\n"
  },
  {
    "path": "specs/api/bruno/favorites/02-setup-create-article.bru",
    "content": "meta {\n  name: Setup: Create article\n  type: http\n  seq: 2\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"title\": \"Favorite Article {{uid}}\",\n      \"description\": \"For favorites\",\n      \"body\": \"Article body\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"slug\", res.body.article.slug);\n}\n"
  },
  {
    "path": "specs/api/bruno/favorites/03-favorite-article.bru",
    "content": "meta {\n  name: Favorite article\n  type: http\n  seq: 3\n}\n\npost {\n  url: {{host}}/api/articles/{{slug}}/favorite\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(typeof res.body.article.title).to.eql(\"string\");\n  expect(typeof res.body.article.slug).to.eql(\"string\");\n  expect(typeof res.body.article.description).to.eql(\"string\");\n  expect(typeof res.body.article.body).to.eql(\"string\");\n  expect(Array.isArray(res.body.article.tagList)).to.eql(true);\n  expect(res.body.article.createdAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.article.updatedAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.article.favorited).to.eql(true);\n  expect(res.body.article.favoritesCount).to.eql(1);\n  expect(res.body.article.author.username).to.eql(\"fav_\" + bru.getVar(\"uid\"));\n}\n"
  },
  {
    "path": "specs/api/bruno/favorites/04-verify-favorite-persists.bru",
    "content": "meta {\n  name: Verify favorite persists\n  type: http\n  seq: 4\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(typeof res.body.article.title).to.eql(\"string\");\n  expect(typeof res.body.article.slug).to.eql(\"string\");\n  expect(typeof res.body.article.description).to.eql(\"string\");\n  expect(typeof res.body.article.body).to.eql(\"string\");\n  expect(Array.isArray(res.body.article.tagList)).to.eql(true);\n  expect(res.body.article.createdAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.article.updatedAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.article.favorited).to.eql(true);\n  expect(res.body.article.favoritesCount).to.eql(1);\n  expect(res.body.article.author.username).to.eql(\"fav_\" + bru.getVar(\"uid\"));\n}\n"
  },
  {
    "path": "specs/api/bruno/favorites/05-articles-filtered-by-favorited-username.bru",
    "content": "meta {\n  name: Articles filtered by favorited username\n  type: http\n  seq: 5\n}\n\nget {\n  url: {{host}}/api/articles?favorited=fav_{{uid}}\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(Array.isArray(res.body.articles)).to.eql(true);\n  expect(Number.isInteger(res.body.articlesCount)).to.eql(true);\n  expect(res.body.articlesCount).to.be.at.least(1);\n  expect(typeof res.body.articles[0].title).to.eql(\"string\");\n  expect(typeof res.body.articles[0].slug).to.eql(\"string\");\n  expect(typeof res.body.articles[0].description).to.eql(\"string\");\n  expect(res.body.articles[0]).to.not.have.property(\"body\");\n  expect(Array.isArray(res.body.articles[0].tagList)).to.eql(true);\n  expect(res.body.articles[0].createdAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.articles[0].updatedAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(typeof res.body.articles[0].favorited).to.eql(\"boolean\");\n  expect(Number.isInteger(res.body.articles[0].favoritesCount)).to.eql(true);\n  expect(res.body.articles[0].favoritesCount).to.be.at.least(1);\n}\n"
  },
  {
    "path": "specs/api/bruno/favorites/06-articles-filtered-by-favorited-username-with-auth.bru",
    "content": "meta {\n  name: Articles filtered by favorited username with auth\n  type: http\n  seq: 6\n}\n\nget {\n  url: {{host}}/api/articles?favorited=fav_{{uid}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(Array.isArray(res.body.articles)).to.eql(true);\n  expect(Number.isInteger(res.body.articlesCount)).to.eql(true);\n  expect(res.body.articlesCount).to.be.at.least(1);\n  expect(typeof res.body.articles[0].title).to.eql(\"string\");\n  expect(typeof res.body.articles[0].slug).to.eql(\"string\");\n  expect(typeof res.body.articles[0].description).to.eql(\"string\");\n  expect(res.body.articles[0]).to.not.have.property(\"body\");\n  expect(Array.isArray(res.body.articles[0].tagList)).to.eql(true);\n  expect(res.body.articles[0].createdAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.articles[0].updatedAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(typeof res.body.articles[0].favorited).to.eql(\"boolean\");\n  expect(Number.isInteger(res.body.articles[0].favoritesCount)).to.eql(true);\n  expect(res.body.articles[0].favoritesCount).to.be.at.least(1);\n}\n"
  },
  {
    "path": "specs/api/bruno/favorites/07-unfavorite-article.bru",
    "content": "meta {\n  name: Unfavorite article\n  type: http\n  seq: 7\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug}}/favorite\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(typeof res.body.article.title).to.eql(\"string\");\n  expect(typeof res.body.article.slug).to.eql(\"string\");\n  expect(typeof res.body.article.description).to.eql(\"string\");\n  expect(typeof res.body.article.body).to.eql(\"string\");\n  expect(Array.isArray(res.body.article.tagList)).to.eql(true);\n  expect(res.body.article.createdAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.article.updatedAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.article.favorited).to.eql(false);\n  expect(res.body.article.favoritesCount).to.eql(0);\n  expect(res.body.article.author.username).to.eql(\"fav_\" + bru.getVar(\"uid\"));\n}\n"
  },
  {
    "path": "specs/api/bruno/favorites/08-verify-unfavorite-persists.bru",
    "content": "meta {\n  name: Verify unfavorite persists\n  type: http\n  seq: 8\n}\n\nget {\n  url: {{host}}/api/articles/{{slug}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(typeof res.body.article.title).to.eql(\"string\");\n  expect(typeof res.body.article.slug).to.eql(\"string\");\n  expect(typeof res.body.article.description).to.eql(\"string\");\n  expect(typeof res.body.article.body).to.eql(\"string\");\n  expect(Array.isArray(res.body.article.tagList)).to.eql(true);\n  expect(res.body.article.createdAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.article.updatedAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.article.favorited).to.eql(false);\n  expect(res.body.article.favoritesCount).to.eql(0);\n  expect(res.body.article.author.username).to.eql(\"fav_\" + bru.getVar(\"uid\"));\n}\n"
  },
  {
    "path": "specs/api/bruno/favorites/09-cleanup.bru",
    "content": "meta {\n  name: Cleanup\n  type: http\n  seq: 9\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 204\n}\n"
  },
  {
    "path": "specs/api/bruno/feed/01-register-main-user.bru",
    "content": "meta {\n  name: Register main user\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"feedm_{{uid}}\",\n      \"email\": \"feedm_{{uid}}@test.com\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"main_token\", res.body.user.token);\n}\n"
  },
  {
    "path": "specs/api/bruno/feed/02-register-celeb-user.bru",
    "content": "meta {\n  name: Register celeb user\n  type: http\n  seq: 2\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"feedc_{{uid}}\",\n      \"email\": \"feedc_{{uid}}@test.com\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"celeb_token\", res.body.user.token);\n}\n"
  },
  {
    "path": "specs/api/bruno/feed/03-feed-for-new-user-returns-empty.bru",
    "content": "meta {\n  name: Feed for new user returns empty\n  type: http\n  seq: 3\n}\n\nget {\n  url: {{host}}/api/articles/feed\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{main_token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.articlesCount).to.eql(0);\n  expect(res.body.articles.length).to.eql(0);\n}\n"
  },
  {
    "path": "specs/api/bruno/feed/04-main-follows-celeb.bru",
    "content": "meta {\n  name: Main follows celeb\n  type: http\n  seq: 4\n}\n\npost {\n  url: {{host}}/api/profiles/feedc_{{uid}}/follow\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{main_token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.profile.following).to.eql(true);\n}\n"
  },
  {
    "path": "specs/api/bruno/feed/05-celeb-creates-article-1.bru",
    "content": "meta {\n  name: Celeb creates article 1\n  type: http\n  seq: 5\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{celeb_token}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"title\": \"Feed Article 1 {{uid}}\",\n      \"description\": \"Feed test 1\",\n      \"body\": \"Feed body 1\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"slug1\", res.body.article.slug);\n}\n"
  },
  {
    "path": "specs/api/bruno/feed/06-celeb-creates-article-2.bru",
    "content": "meta {\n  name: Celeb creates article 2\n  type: http\n  seq: 6\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{celeb_token}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"title\": \"Feed Article 2 {{uid}}\",\n      \"description\": \"Feed test 2\",\n      \"body\": \"Feed body 2\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"slug2\", res.body.article.slug);\n}\n"
  },
  {
    "path": "specs/api/bruno/feed/07-main-checks-feed.bru",
    "content": "meta {\n  name: Main checks feed\n  type: http\n  seq: 7\n}\n\nget {\n  url: {{host}}/api/articles/feed\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{main_token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(Array.isArray(res.body.articles)).to.eql(true);\n  expect(Number.isInteger(res.body.articlesCount)).to.eql(true);\n  expect(res.body.articlesCount).to.eql(2);\n  expect(res.body.articles.length).to.eql(2);\n  expect(typeof res.body.articles[0].title).to.eql(\"string\");\n  expect(typeof res.body.articles[0].slug).to.eql(\"string\");\n  expect(typeof res.body.articles[0].description).to.eql(\"string\");\n  expect(res.body.articles[0]).to.not.have.property(\"body\");\n  expect(Array.isArray(res.body.articles[0].tagList)).to.eql(true);\n  expect(res.body.articles[0].createdAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.articles[0].updatedAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(typeof res.body.articles[0].favorited).to.eql(\"boolean\");\n  expect(Number.isInteger(res.body.articles[0].favoritesCount)).to.eql(true);\n  expect(res.body.articles[0].author.username).to.eql(\"feedc_\" + bru.getVar(\"uid\"));\n}\n"
  },
  {
    "path": "specs/api/bruno/feed/08-feed-with-limit-1.bru",
    "content": "meta {\n  name: Feed with limit=1\n  type: http\n  seq: 8\n}\n\nget {\n  url: {{host}}/api/articles/feed?limit=1\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{main_token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.articles.length).to.eql(1);\n  expect(res.body.articlesCount).to.eql(2);\n  expect(typeof res.body.articles[0].title).to.eql(\"string\");\n  expect(typeof res.body.articles[0].slug).to.eql(\"string\");\n  expect(typeof res.body.articles[0].description).to.eql(\"string\");\n  expect(res.body.articles[0]).to.not.have.property(\"body\");\n  expect(Array.isArray(res.body.articles[0].tagList)).to.eql(true);\n  expect(res.body.articles[0].createdAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(res.body.articles[0].updatedAt).to.match(/^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}/);\n  expect(typeof res.body.articles[0].favorited).to.eql(\"boolean\");\n  expect(Number.isInteger(res.body.articles[0].favoritesCount)).to.eql(true);\n  expect(res.body.articles[0].author.username).to.eql(\"feedc_\" + bru.getVar(\"uid\"));\n  expect(res.body.articles[0].author.following).to.eql(true);\n}\n"
  },
  {
    "path": "specs/api/bruno/feed/09-feed-with-limit-1-offset-1.bru",
    "content": "meta {\n  name: Feed with limit=1&offset=1\n  type: http\n  seq: 9\n}\n\nget {\n  url: {{host}}/api/articles/feed?limit=1&offset=1\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{main_token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.articles.length).to.eql(1);\n  expect(res.body.articlesCount).to.eql(2);\n}\n"
  },
  {
    "path": "specs/api/bruno/feed/10-cleanup-delete-articles.bru",
    "content": "meta {\n  name: Cleanup: delete articles\n  type: http\n  seq: 10\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug1}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{celeb_token}}\n}\n\nassert {\n  res.status: eq 204\n}\n"
  },
  {
    "path": "specs/api/bruno/feed/11-delete-slug2.bru",
    "content": "meta {\n  name: DELETE {{slug2}}\n  type: http\n  seq: 11\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug2}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{celeb_token}}\n}\n\nassert {\n  res.status: eq 204\n}\n"
  },
  {
    "path": "specs/api/bruno/feed/12-cleanup-unfollow.bru",
    "content": "meta {\n  name: Cleanup: unfollow\n  type: http\n  seq: 12\n}\n\ndelete {\n  url: {{host}}/api/profiles/feedc_{{uid}}/follow\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{main_token}}\n}\n\nassert {\n  res.status: eq 200\n}\n"
  },
  {
    "path": "specs/api/bruno/pagination/01-setup-register.bru",
    "content": "meta {\n  name: Setup: Register\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"page_{{uid}}\",\n      \"email\": \"page_{{uid}}@test.com\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"token\", res.body.user.token);\n}\n"
  },
  {
    "path": "specs/api/bruno/pagination/02-create-article-1.bru",
    "content": "meta {\n  name: Create article 1\n  type: http\n  seq: 2\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"title\": \"Pagination 1 {{uid}}\",\n      \"description\": \"Page test 1\",\n      \"body\": \"Page body 1\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"slug1\", res.body.article.slug);\n}\n"
  },
  {
    "path": "specs/api/bruno/pagination/03-create-article-2.bru",
    "content": "meta {\n  name: Create article 2\n  type: http\n  seq: 3\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"title\": \"Pagination 2 {{uid}}\",\n      \"description\": \"Page test 2\",\n      \"body\": \"Page body 2\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"slug2\", res.body.article.slug);\n  expect(res.body.article.title).to.eql(\"Pagination 2 \" + bru.getVar(\"uid\"));\n  expect(typeof res.body.article.slug).to.eql(\"string\");\n}\n"
  },
  {
    "path": "specs/api/bruno/pagination/04-list-with-limit-1-most-recent-first-so-slug2.bru",
    "content": "meta {\n  name: List with limit=1 (most recent first, so slug2)\n  type: http\n  seq: 4\n}\n\nget {\n  url: {{host}}/api/articles?author=page_{{uid}}&limit=1\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.articles.length).to.eql(1);\n  expect(res.body.articlesCount).to.eql(2);\n  expect(res.body.articles[0].slug).to.eql(bru.getVar(\"slug2\"));\n}\n"
  },
  {
    "path": "specs/api/bruno/pagination/05-list-with-limit-1-offset-1-second-page-so-slug1.bru",
    "content": "meta {\n  name: List with limit=1&offset=1 (second page, so slug1)\n  type: http\n  seq: 5\n}\n\nget {\n  url: {{host}}/api/articles?author=page_{{uid}}&limit=1&offset=1\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.articles.length).to.eql(1);\n  expect(res.body.articlesCount).to.eql(2);\n  expect(res.body.articles[0].slug).to.eql(bru.getVar(\"slug1\"));\n}\n"
  },
  {
    "path": "specs/api/bruno/pagination/06-cleanup.bru",
    "content": "meta {\n  name: Cleanup\n  type: http\n  seq: 6\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug1}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 204\n}\n"
  },
  {
    "path": "specs/api/bruno/pagination/07-delete-slug2.bru",
    "content": "meta {\n  name: DELETE {{slug2}}\n  type: http\n  seq: 7\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug2}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 204\n}\n"
  },
  {
    "path": "specs/api/bruno/profiles/01-register-main-user.bru",
    "content": "meta {\n  name: Register main user\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"prof_{{uid}}\",\n      \"email\": \"prof_{{uid}}@test.com\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"token\", res.body.user.token);\n}\n"
  },
  {
    "path": "specs/api/bruno/profiles/02-register-celeb-user.bru",
    "content": "meta {\n  name: Register celeb user\n  type: http\n  seq: 2\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"celeb_{{uid}}\",\n      \"email\": \"celeb_{{uid}}@test.com\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n"
  },
  {
    "path": "specs/api/bruno/profiles/03-get-profile-without-auth.bru",
    "content": "meta {\n  name: Get profile without auth\n  type: http\n  seq: 3\n}\n\nget {\n  url: {{host}}/api/profiles/celeb_{{uid}}\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.profile.username).to.eql(\"celeb_\" + bru.getVar(\"uid\"));\n  expect(res.body.profile.bio).to.be.null;\n  expect(res.body.profile.image).to.be.null;\n  expect(res.body.profile.following).to.eql(false);\n}\n"
  },
  {
    "path": "specs/api/bruno/profiles/04-get-profile-with-auth.bru",
    "content": "meta {\n  name: Get profile with auth\n  type: http\n  seq: 4\n}\n\nget {\n  url: {{host}}/api/profiles/celeb_{{uid}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.profile.username).to.eql(\"celeb_\" + bru.getVar(\"uid\"));\n  expect(res.body.profile.bio).to.be.null;\n  expect(res.body.profile.image).to.be.null;\n  expect(res.body.profile.following).to.eql(false);\n}\n"
  },
  {
    "path": "specs/api/bruno/profiles/05-follow-profile.bru",
    "content": "meta {\n  name: Follow profile\n  type: http\n  seq: 5\n}\n\npost {\n  url: {{host}}/api/profiles/celeb_{{uid}}/follow\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.profile.username).to.eql(\"celeb_\" + bru.getVar(\"uid\"));\n  expect(res.body.profile.bio).to.be.null;\n  expect(res.body.profile.image).to.be.null;\n  expect(res.body.profile.following).to.eql(true);\n}\n"
  },
  {
    "path": "specs/api/bruno/profiles/06-unfollow-profile.bru",
    "content": "meta {\n  name: Unfollow profile\n  type: http\n  seq: 6\n}\n\ndelete {\n  url: {{host}}/api/profiles/celeb_{{uid}}/follow\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.profile.username).to.eql(\"celeb_\" + bru.getVar(\"uid\"));\n  expect(res.body.profile.bio).to.be.null;\n  expect(res.body.profile.image).to.be.null;\n  expect(res.body.profile.following).to.eql(false);\n}\n"
  },
  {
    "path": "specs/api/bruno/profiles/07-verify-unfollow-persisted.bru",
    "content": "meta {\n  name: Verify unfollow persisted\n  type: http\n  seq: 7\n}\n\nget {\n  url: {{host}}/api/profiles/celeb_{{uid}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(res.body.profile.username).to.eql(\"celeb_\" + bru.getVar(\"uid\"));\n  expect(res.body.profile.bio).to.be.null;\n  expect(res.body.profile.image).to.be.null;\n  expect(res.body.profile.following).to.eql(false);\n}\n"
  },
  {
    "path": "specs/api/bruno/tags/01-setup-register.bru",
    "content": "meta {\n  name: Setup: Register\n  type: http\n  seq: 1\n}\n\npost {\n  url: {{host}}/api/users\n  body: json\n  auth: none\n}\n\nbody:json {\n  {\n    \"user\": {\n      \"username\": \"tag_{{uid}}\",\n      \"email\": \"tag_{{uid}}@test.com\",\n      \"password\": \"password123\"\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"token\", res.body.user.token);\n}\n"
  },
  {
    "path": "specs/api/bruno/tags/02-setup-create-article-with-tags.bru",
    "content": "meta {\n  name: Setup: Create article with tags\n  type: http\n  seq: 2\n}\n\npost {\n  url: {{host}}/api/articles\n  body: json\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nbody:json {\n  {\n    \"article\": {\n      \"title\": \"Tag Article {{uid}}\",\n      \"description\": \"For tags\",\n      \"body\": \"Article body\",\n      \"tagList\": [\"h_{{uid}}\", \"t_{{uid}}\"]\n    }\n  }\n}\n\nassert {\n  res.status: eq 201\n}\n\nscript:post-response {\n  bru.setVar(\"slug\", res.body.article.slug);\n}\n"
  },
  {
    "path": "specs/api/bruno/tags/03-get-tags.bru",
    "content": "meta {\n  name: Get tags\n  type: http\n  seq: 3\n}\n\nget {\n  url: {{host}}/api/tags\n  body: none\n  auth: none\n}\n\nassert {\n  res.status: eq 200\n}\n\nscript:post-response {\n  expect(Array.isArray(res.body.tags)).to.eql(true);\n  expect(res.body.tags.length).to.be.at.least(1);\n  expect(res.body.tags).to.include(\"h_\" + bru.getVar(\"uid\"));\n  expect(res.body.tags).to.include(\"t_\" + bru.getVar(\"uid\"));\n  expect(typeof res.body.tags[0]).to.eql(\"string\");\n  expect(typeof res.body.tags[1]).to.eql(\"string\");\n}\n"
  },
  {
    "path": "specs/api/bruno/tags/04-cleanup.bru",
    "content": "meta {\n  name: Cleanup\n  type: http\n  seq: 4\n}\n\ndelete {\n  url: {{host}}/api/articles/{{slug}}\n  body: none\n  auth: none\n}\n\nheaders {\n  Authorization: Token {{token}}\n}\n\nassert {\n  res.status: eq 204\n}\n"
  },
  {
    "path": "specs/api/hurl/articles.hurl",
    "content": "# Setup: Register\nPOST {{host}}/api/users\n{\n  \"user\": {\n    \"username\": \"art_{{uid}}\",\n    \"email\": \"art_{{uid}}@test.com\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 201\n[Captures]\ntoken: jsonpath \"$.user.token\"\n\n# Create article with tags\nPOST {{host}}/api/articles\nAuthorization: Token {{token}}\n{\n  \"article\": {\n    \"title\": \"Test Article {{uid}}\",\n    \"description\": \"Test description\",\n    \"body\": \"Test body content\",\n    \"tagList\": [\"d_{{uid}}\", \"t_{{uid}}\"]\n  }\n}\nHTTP 201\n[Asserts]\njsonpath \"$.article.title\" == \"Test Article {{uid}}\"\njsonpath \"$.article.slug\" isString\njsonpath \"$.article.description\" == \"Test description\"\njsonpath \"$.article.body\" == \"Test body content\"\njsonpath \"$.article.tagList\" contains \"d_{{uid}}\"\njsonpath \"$.article.tagList\" contains \"t_{{uid}}\"\njsonpath \"$.article.tagList[0]\" == \"d_{{uid}}\"\njsonpath \"$.article.tagList[1]\" == \"t_{{uid}}\"\njsonpath \"$.article.createdAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\"\njsonpath \"$.article.updatedAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\"\njsonpath \"$.article.favorited\" == false\njsonpath \"$.article.favoritesCount\" == 0\njsonpath \"$.article.author.username\" == \"art_{{uid}}\"\n[Captures]\nslug: jsonpath \"$.article.slug\"\ncreated_at: jsonpath \"$.article.createdAt\"\nupdated_at: jsonpath \"$.article.updatedAt\"\n\n# List all articles\nGET {{host}}/api/articles\nHTTP 200\n[Asserts]\njsonpath \"$.articles\" isList\njsonpath \"$.articlesCount\" isInteger\njsonpath \"$.articlesCount\" >= 1\njsonpath \"$.articles[0].title\" isString\njsonpath \"$.articles[0].slug\" isString\njsonpath \"$.articles[0].description\" isString\njsonpath \"$.articles[0].body\" not exists\njsonpath \"$.articles[0].tagList\" isList\njsonpath \"$.articles[0].createdAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.articles[0].updatedAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.articles[0].favorited\" isBoolean\njsonpath \"$.articles[0].favoritesCount\" isInteger\njsonpath \"$.articles[0].author.username\" isString\n\n# List by author\nGET {{host}}/api/articles?author=art_{{uid}}\nHTTP 200\n[Asserts]\njsonpath \"$.articles\" isList\njsonpath \"$.articlesCount\" isInteger\njsonpath \"$.articlesCount\" >= 1\njsonpath \"$.articles[0].title\" isString\njsonpath \"$.articles[0].slug\" isString\njsonpath \"$.articles[0].description\" isString\njsonpath \"$.articles[0].body\" not exists\njsonpath \"$.articles[0].tagList\" isList\njsonpath \"$.articles[0].createdAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.articles[0].updatedAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.articles[0].favorited\" isBoolean\njsonpath \"$.articles[0].favoritesCount\" isInteger\njsonpath \"$.articles[0].author.username\" == \"art_{{uid}}\"\n\n# List all articles with auth\nGET {{host}}/api/articles\nAuthorization: Token {{token}}\nHTTP 200\n[Asserts]\njsonpath \"$.articles\" isList\njsonpath \"$.articlesCount\" isInteger\njsonpath \"$.articlesCount\" >= 1\njsonpath \"$.articles[0].title\" isString\njsonpath \"$.articles[0].slug\" isString\njsonpath \"$.articles[0].description\" isString\njsonpath \"$.articles[0].body\" not exists\njsonpath \"$.articles[0].tagList\" isList\njsonpath \"$.articles[0].createdAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.articles[0].updatedAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.articles[0].favorited\" isBoolean\njsonpath \"$.articles[0].favoritesCount\" isInteger\njsonpath \"$.articles[0].author.username\" isString\n\n# List by author with auth\nGET {{host}}/api/articles?author=art_{{uid}}\nAuthorization: Token {{token}}\nHTTP 200\n[Asserts]\njsonpath \"$.articles\" isList\njsonpath \"$.articlesCount\" isInteger\njsonpath \"$.articlesCount\" >= 1\njsonpath \"$.articles[0].title\" isString\njsonpath \"$.articles[0].slug\" isString\njsonpath \"$.articles[0].description\" isString\njsonpath \"$.articles[0].body\" not exists\njsonpath \"$.articles[0].tagList\" isList\njsonpath \"$.articles[0].createdAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.articles[0].updatedAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.articles[0].favorited\" isBoolean\njsonpath \"$.articles[0].favoritesCount\" isInteger\njsonpath \"$.articles[0].author.username\" == \"art_{{uid}}\"\n\n# List by tag\nGET {{host}}/api/articles?tag=d_{{uid}}\nHTTP 200\n[Asserts]\njsonpath \"$.articles\" isList\njsonpath \"$.articlesCount\" isInteger\njsonpath \"$.articlesCount\" >= 1\njsonpath \"$.articles[0].title\" isString\njsonpath \"$.articles[0].slug\" isString\njsonpath \"$.articles[0].description\" isString\njsonpath \"$.articles[0].body\" not exists\njsonpath \"$.articles[0].tagList\" isList\njsonpath \"$.articles[0].tagList\" contains \"d_{{uid}}\"\njsonpath \"$.articles[0].createdAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.articles[0].updatedAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.articles[0].favorited\" isBoolean\njsonpath \"$.articles[0].favoritesCount\" isInteger\njsonpath \"$.articles[0].author.username\" isString\n\n# List articles without auth\nGET {{host}}/api/articles\nHTTP 200\n[Asserts]\njsonpath \"$.articles\" isList\njsonpath \"$.articlesCount\" isInteger\n\n# Get single article\nGET {{host}}/api/articles/{{slug}}\nHTTP 200\n[Asserts]\njsonpath \"$.article.title\" == \"Test Article {{uid}}\"\njsonpath \"$.article.slug\" == \"{{slug}}\"\njsonpath \"$.article.description\" == \"Test description\"\njsonpath \"$.article.body\" == \"Test body content\"\njsonpath \"$.article.tagList\" isList\njsonpath \"$.article.createdAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.article.updatedAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.article.favorited\" == false\njsonpath \"$.article.favoritesCount\" == 0\njsonpath \"$.article.author.username\" == \"art_{{uid}}\"\n\n# Update article body\nPUT {{host}}/api/articles/{{slug}}\nAuthorization: Token {{token}}\n{\n  \"article\": {\n    \"body\": \"Updated body content\"\n  }\n}\nHTTP 200\n[Asserts]\njsonpath \"$.article.title\" == \"Test Article {{uid}}\"\njsonpath \"$.article.slug\" == \"{{slug}}\"\njsonpath \"$.article.description\" == \"Test description\"\njsonpath \"$.article.body\" == \"Updated body content\"\njsonpath \"$.article.tagList\" isList\njsonpath \"$.article.tagList\" count == 2\njsonpath \"$.article.tagList\" contains \"d_{{uid}}\"\njsonpath \"$.article.tagList\" contains \"t_{{uid}}\"\njsonpath \"$.article.createdAt\" == \"{{created_at}}\"\njsonpath \"$.article.updatedAt\" != \"{{updated_at}}\"\njsonpath \"$.article.favorited\" isBoolean\njsonpath \"$.article.favoritesCount\" isInteger\njsonpath \"$.article.author.username\" == \"art_{{uid}}\"\n\n# Verify update persisted\nGET {{host}}/api/articles/{{slug}}\nHTTP 200\n[Asserts]\njsonpath \"$.article.title\" == \"Test Article {{uid}}\"\njsonpath \"$.article.slug\" == \"{{slug}}\"\njsonpath \"$.article.description\" == \"Test description\"\njsonpath \"$.article.body\" == \"Updated body content\"\njsonpath \"$.article.tagList\" isList\njsonpath \"$.article.tagList\" count == 2\njsonpath \"$.article.tagList\" contains \"d_{{uid}}\"\njsonpath \"$.article.tagList\" contains \"t_{{uid}}\"\njsonpath \"$.article.createdAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.article.updatedAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.article.favorited\" isBoolean\njsonpath \"$.article.favoritesCount\" isInteger\njsonpath \"$.article.author.username\" == \"art_{{uid}}\"\n\n# Update article without tagList: tags should be preserved\nPUT {{host}}/api/articles/{{slug}}\nAuthorization: Token {{token}}\n{\n  \"article\": {\n    \"body\": \"Body without touching tags\"\n  }\n}\nHTTP 200\n[Asserts]\njsonpath \"$.article.body\" == \"Body without touching tags\"\njsonpath \"$.article.tagList\" count == 2\njsonpath \"$.article.tagList\" contains \"d_{{uid}}\"\njsonpath \"$.article.tagList\" contains \"t_{{uid}}\"\n\n# Update article: remove all tags with empty array\nPUT {{host}}/api/articles/{{slug}}\nAuthorization: Token {{token}}\n{\n  \"article\": {\n    \"tagList\": []\n  }\n}\nHTTP 200\n[Asserts]\njsonpath \"$.article.tagList\" isList\njsonpath \"$.article.tagList\" count == 0\n\n# Verify tags were actually removed\nGET {{host}}/api/articles/{{slug}}\nHTTP 200\n[Asserts]\njsonpath \"$.article.tagList\" isList\njsonpath \"$.article.tagList\" count == 0\n\n# Update article: tagList null should be rejected\nPUT {{host}}/api/articles/{{slug}}\nAuthorization: Token {{token}}\n{\n  \"article\": {\n    \"tagList\": null\n  }\n}\nHTTP 422\n\n# Delete article\nDELETE {{host}}/api/articles/{{slug}}\nAuthorization: Token {{token}}\nHTTP 204\n\n# Verify deletion\nGET {{host}}/api/articles/{{slug}}\nHTTP 404\n[Asserts]\njsonpath \"$.errors.article[0]\" == \"not found\"\n"
  },
  {
    "path": "specs/api/hurl/auth.hurl",
    "content": "# Register\nPOST {{host}}/api/users\n{\n  \"user\": {\n    \"username\": \"auth_{{uid}}\",\n    \"email\": \"auth_{{uid}}@test.com\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 201\n[Asserts]\njsonpath \"$.user.username\" == \"auth_{{uid}}\"\njsonpath \"$.user.email\" == \"auth_{{uid}}@test.com\"\njsonpath \"$.user.bio\" == null\njsonpath \"$.user.image\" == null\njsonpath \"$.user.token\" isString\njsonpath \"$.user.token\" not isEmpty\n[Captures]\nreg_token: jsonpath \"$.user.token\"\n\n# Login\nPOST {{host}}/api/users/login\n{\n  \"user\": {\n    \"email\": \"auth_{{uid}}@test.com\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 200\n[Asserts]\njsonpath \"$.user.username\" == \"auth_{{uid}}\"\njsonpath \"$.user.email\" == \"auth_{{uid}}@test.com\"\njsonpath \"$.user.bio\" == null\njsonpath \"$.user.image\" == null\njsonpath \"$.user.token\" isString\njsonpath \"$.user.token\" not isEmpty\n[Captures]\ntoken: jsonpath \"$.user.token\"\n\n# Get current user\nGET {{host}}/api/user\nAuthorization: Token {{token}}\nHTTP 200\n[Asserts]\njsonpath \"$.user.username\" == \"auth_{{uid}}\"\njsonpath \"$.user.email\" == \"auth_{{uid}}@test.com\"\njsonpath \"$.user.bio\" == null\njsonpath \"$.user.image\" == null\njsonpath \"$.user.token\" isString\njsonpath \"$.user.token\" not isEmpty\n\n# Update user\nPUT {{host}}/api/user\nAuthorization: Token {{token}}\n{\n  \"user\": {\n    \"bio\": \"Updated bio\"\n  }\n}\nHTTP 200\n[Asserts]\njsonpath \"$.user.username\" == \"auth_{{uid}}\"\njsonpath \"$.user.email\" == \"auth_{{uid}}@test.com\"\njsonpath \"$.user.bio\" == \"Updated bio\"\njsonpath \"$.user.image\" == null\njsonpath \"$.user.token\" isString\njsonpath \"$.user.token\" not isEmpty\n\n# Verify update persisted\nGET {{host}}/api/user\nAuthorization: Token {{token}}\nHTTP 200\n[Asserts]\njsonpath \"$.user.username\" == \"auth_{{uid}}\"\njsonpath \"$.user.email\" == \"auth_{{uid}}@test.com\"\njsonpath \"$.user.bio\" == \"Updated bio\"\njsonpath \"$.user.image\" == null\njsonpath \"$.user.token\" isString\njsonpath \"$.user.token\" not isEmpty\n\n# Update user bio to empty string - should normalize to null\nPUT {{host}}/api/user\nAuthorization: Token {{token}}\n{\n  \"user\": {\n    \"bio\": \"\"\n  }\n}\nHTTP 200\n[Asserts]\njsonpath \"$.user.bio\" == null\n\n# Verify empty string normalization persisted\nGET {{host}}/api/user\nAuthorization: Token {{token}}\nHTTP 200\n[Asserts]\njsonpath \"$.user.bio\" == null\n\n# Restore bio then set to null\nPUT {{host}}/api/user\nAuthorization: Token {{token}}\n{\n  \"user\": {\n    \"bio\": \"Temporary bio\"\n  }\n}\nHTTP 200\n[Asserts]\njsonpath \"$.user.bio\" == \"Temporary bio\"\n\n# Update user bio to null - should accept for nullable field\nPUT {{host}}/api/user\nAuthorization: Token {{token}}\n{\n  \"user\": {\n    \"bio\": null\n  }\n}\nHTTP 200\n[Asserts]\njsonpath \"$.user.bio\" == null\n\n# Verify null bio persisted\nGET {{host}}/api/user\nAuthorization: Token {{token}}\nHTTP 200\n[Asserts]\njsonpath \"$.user.bio\" == null\n\n# Restore bio\nPUT {{host}}/api/user\nAuthorization: Token {{token}}\n{\n  \"user\": {\n    \"bio\": \"Updated bio\"\n  }\n}\nHTTP 200\n[Asserts]\njsonpath \"$.user.username\" == \"auth_{{uid}}\"\njsonpath \"$.user.email\" == \"auth_{{uid}}@test.com\"\njsonpath \"$.user.bio\" == \"Updated bio\"\njsonpath \"$.user.image\" == null\njsonpath \"$.user.token\" isString\njsonpath \"$.user.token\" not isEmpty\n\n# Update user image\nPUT {{host}}/api/user\nAuthorization: Token {{token}}\n{\n  \"user\": {\n    \"image\": \"https://example.com/photo.jpg\"\n  }\n}\nHTTP 200\n[Asserts]\njsonpath \"$.user.image\" == \"https://example.com/photo.jpg\"\n\n# Verify image update persisted\nGET {{host}}/api/user\nAuthorization: Token {{token}}\nHTTP 200\n[Asserts]\njsonpath \"$.user.image\" == \"https://example.com/photo.jpg\"\n\n# Update image to empty string - should normalize to null\nPUT {{host}}/api/user\nAuthorization: Token {{token}}\n{\n  \"user\": {\n    \"image\": \"\"\n  }\n}\nHTTP 200\n[Asserts]\njsonpath \"$.user.image\" == null\n\n# Verify image empty string normalization persisted\nGET {{host}}/api/user\nAuthorization: Token {{token}}\nHTTP 200\n[Asserts]\njsonpath \"$.user.image\" == null\n\n# Set image then update to null - should accept for nullable field\nPUT {{host}}/api/user\nAuthorization: Token {{token}}\n{\n  \"user\": {\n    \"image\": \"https://example.com/temp.jpg\"\n  }\n}\nHTTP 200\n[Asserts]\njsonpath \"$.user.image\" == \"https://example.com/temp.jpg\"\n\nPUT {{host}}/api/user\nAuthorization: Token {{token}}\n{\n  \"user\": {\n    \"image\": null\n  }\n}\nHTTP 200\n[Asserts]\njsonpath \"$.user.image\" == null\n\n# Verify null image persisted\nGET {{host}}/api/user\nAuthorization: Token {{token}}\nHTTP 200\n[Asserts]\njsonpath \"$.user.image\" == null\n\n# Update username and email\nPUT {{host}}/api/user\nAuthorization: Token {{token}}\n{\n  \"user\": {\n    \"username\": \"auth_{{uid}}_upd\",\n    \"email\": \"auth_{{uid}}_upd@test.com\"\n  }\n}\nHTTP 200\n[Asserts]\njsonpath \"$.user.username\" == \"auth_{{uid}}_upd\"\njsonpath \"$.user.email\" == \"auth_{{uid}}_upd@test.com\"\njsonpath \"$.user.bio\" == \"Updated bio\"\njsonpath \"$.user.image\" == null\njsonpath \"$.user.token\" isString\njsonpath \"$.user.token\" not isEmpty\n[Captures]\nupdated_token: jsonpath \"$.user.token\"\n\n# Verify username/email update persisted\nGET {{host}}/api/user\nAuthorization: Token {{updated_token}}\nHTTP 200\n[Asserts]\njsonpath \"$.user.username\" == \"auth_{{uid}}_upd\"\njsonpath \"$.user.email\" == \"auth_{{uid}}_upd@test.com\"\njsonpath \"$.user.bio\" == \"Updated bio\"\njsonpath \"$.user.image\" == null\njsonpath \"$.user.token\" isString\njsonpath \"$.user.token\" not isEmpty\n"
  },
  {
    "path": "specs/api/hurl/comments.hurl",
    "content": "# Setup: Register\nPOST {{host}}/api/users\n{\n  \"user\": {\n    \"username\": \"cmt_{{uid}}\",\n    \"email\": \"cmt_{{uid}}@test.com\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 201\n[Captures]\ntoken: jsonpath \"$.user.token\"\n\n# Setup: Create article\nPOST {{host}}/api/articles\nAuthorization: Token {{token}}\n{\n  \"article\": {\n    \"title\": \"Comment Article {{uid}}\",\n    \"description\": \"For comments\",\n    \"body\": \"Article body\"\n  }\n}\nHTTP 201\n[Captures]\nslug: jsonpath \"$.article.slug\"\n\n# Create comment\nPOST {{host}}/api/articles/{{slug}}/comments\nAuthorization: Token {{token}}\n{\n  \"comment\": {\n    \"body\": \"Test comment body\"\n  }\n}\nHTTP 201\n[Asserts]\njsonpath \"$.comment.id\" isInteger\njsonpath \"$.comment.body\" == \"Test comment body\"\njsonpath \"$.comment.createdAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.comment.updatedAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.comment.author.username\" == \"cmt_{{uid}}\"\n[Captures]\ncomment_id: jsonpath \"$.comment.id\"\n\n# List comments\nGET {{host}}/api/articles/{{slug}}/comments\nAuthorization: Token {{token}}\nHTTP 200\n[Asserts]\njsonpath \"$.comments\" isList\njsonpath \"$.comments\" count == 1\njsonpath \"$.comments[0].id\" == {{comment_id}}\njsonpath \"$.comments[0].body\" == \"Test comment body\"\njsonpath \"$.comments[0].createdAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.comments[0].updatedAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.comments[0].author.username\" == \"cmt_{{uid}}\"\n\n# List comments without auth\nGET {{host}}/api/articles/{{slug}}/comments\nHTTP 200\n[Asserts]\njsonpath \"$.comments\" isList\njsonpath \"$.comments\" count == 1\njsonpath \"$.comments[0].id\" isInteger\njsonpath \"$.comments[0].body\" == \"Test comment body\"\njsonpath \"$.comments[0].createdAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.comments[0].updatedAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.comments[0].author.username\" == \"cmt_{{uid}}\"\n\n# Delete comment\nDELETE {{host}}/api/articles/{{slug}}/comments/{{comment_id}}\nAuthorization: Token {{token}}\nHTTP 204\n\n# Verify deletion\nGET {{host}}/api/articles/{{slug}}/comments\nHTTP 200\n[Asserts]\njsonpath \"$.comments\" count == 0\n\n# Selective deletion: create two comments, delete one, verify the other remains\nPOST {{host}}/api/articles/{{slug}}/comments\nAuthorization: Token {{token}}\n{\n  \"comment\": {\n    \"body\": \"First comment\"\n  }\n}\nHTTP 201\n[Captures]\nfirst_comment_id: jsonpath \"$.comment.id\"\n\nPOST {{host}}/api/articles/{{slug}}/comments\nAuthorization: Token {{token}}\n{\n  \"comment\": {\n    \"body\": \"Second comment\"\n  }\n}\nHTTP 201\n\n# Verify two comments exist\nGET {{host}}/api/articles/{{slug}}/comments\nHTTP 200\n[Asserts]\njsonpath \"$.comments\" count == 2\n\n# Delete the first comment\nDELETE {{host}}/api/articles/{{slug}}/comments/{{first_comment_id}}\nAuthorization: Token {{token}}\nHTTP 204\n\n# Verify only the second comment remains\nGET {{host}}/api/articles/{{slug}}/comments\nHTTP 200\n[Asserts]\njsonpath \"$.comments\" count == 1\njsonpath \"$.comments[0].body\" == \"Second comment\"\n\n# Cleanup\nDELETE {{host}}/api/articles/{{slug}}\nAuthorization: Token {{token}}\nHTTP 204\n"
  },
  {
    "path": "specs/api/hurl/errors_articles.hurl",
    "content": "# Create article no auth\nPOST {{host}}/api/articles\n{\n  \"article\": {\n    \"title\": \"No Auth Article\",\n    \"description\": \"test\",\n    \"body\": \"test\"\n  }\n}\nHTTP 401\n[Asserts]\njsonpath \"$.errors.token[0]\" == \"is missing\"\n\n# GET unknown slug\nGET {{host}}/api/articles/unknown-slug-{{uid}}\nHTTP 404\n[Asserts]\njsonpath \"$.errors.article[0]\" == \"not found\"\n\n# Update no auth\nPUT {{host}}/api/articles/some-slug\n{\n  \"article\": {\n    \"body\": \"test\"\n  }\n}\nHTTP 401\n[Asserts]\njsonpath \"$.errors.token[0]\" == \"is missing\"\n\n# Delete no auth\nDELETE {{host}}/api/articles/some-slug\nHTTP 401\n[Asserts]\njsonpath \"$.errors.token[0]\" == \"is missing\"\n\n# GET feed no auth\nGET {{host}}/api/articles/feed\nHTTP 401\n[Asserts]\njsonpath \"$.errors.token[0]\" == \"is missing\"\n\n# Favorite no auth\nPOST {{host}}/api/articles/some-slug/favorite\nHTTP 401\n[Asserts]\njsonpath \"$.errors.token[0]\" == \"is missing\"\n\n# Unfavorite no auth\nDELETE {{host}}/api/articles/some-slug/favorite\nHTTP 401\n[Asserts]\njsonpath \"$.errors.token[0]\" == \"is missing\"\n\n# Setup: Register for authenticated error tests\nPOST {{host}}/api/users\n{\n  \"user\": {\n    \"username\": \"ea_art_{{uid}}\",\n    \"email\": \"ea_art_{{uid}}@test.com\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 201\n[Captures]\ntoken: jsonpath \"$.user.token\"\n\n# Create article empty title\nPOST {{host}}/api/articles\nAuthorization: Token {{token}}\n{\n  \"article\": {\n    \"title\": \"\",\n    \"description\": \"test\",\n    \"body\": \"test\"\n  }\n}\nHTTP 422\n[Asserts]\njsonpath \"$.errors.title[0]\" == \"can't be blank\"\n\n# Create article empty description\nPOST {{host}}/api/articles\nAuthorization: Token {{token}}\n{\n  \"article\": {\n    \"title\": \"Err Desc {{uid}}\",\n    \"description\": \"\",\n    \"body\": \"test\"\n  }\n}\nHTTP 422\n[Asserts]\njsonpath \"$.errors.description[0]\" == \"can't be blank\"\n\n# Create article empty body\nPOST {{host}}/api/articles\nAuthorization: Token {{token}}\n{\n  \"article\": {\n    \"title\": \"Err Body {{uid}}\",\n    \"description\": \"test\",\n    \"body\": \"\"\n  }\n}\nHTTP 422\n[Asserts]\njsonpath \"$.errors.body[0]\" == \"can't be blank\"\n\n# Duplicate titles are allowed (each gets a unique slug)\nPOST {{host}}/api/articles\nAuthorization: Token {{token}}\n{\n  \"article\": {\n    \"title\": \"Dup Title {{uid}}\",\n    \"description\": \"first\",\n    \"body\": \"first\"\n  }\n}\nHTTP 201\n[Captures]\nslug1: jsonpath \"$.article.slug\"\n\nPOST {{host}}/api/articles\nAuthorization: Token {{token}}\n{\n  \"article\": {\n    \"title\": \"Dup Title {{uid}}\",\n    \"description\": \"second\",\n    \"body\": \"second\"\n  }\n}\nHTTP 201\n[Captures]\nslug2: jsonpath \"$.article.slug\"\n[Asserts]\njsonpath \"$.article.slug\" != {{slug1}}\n\n# Update unknown slug\nPUT {{host}}/api/articles/unknown-slug-{{uid}}\nAuthorization: Token {{token}}\n{\n  \"article\": {\n    \"body\": \"test\"\n  }\n}\nHTTP 404\n[Asserts]\njsonpath \"$.errors.article[0]\" == \"not found\"\n\n# Favorite unknown slug\nPOST {{host}}/api/articles/unknown-slug-{{uid}}/favorite\nAuthorization: Token {{token}}\nHTTP 404\n[Asserts]\njsonpath \"$.errors.article[0]\" == \"not found\"\n\n# Unfavorite unknown slug\nDELETE {{host}}/api/articles/unknown-slug-{{uid}}/favorite\nAuthorization: Token {{token}}\nHTTP 404\n[Asserts]\njsonpath \"$.errors.article[0]\" == \"not found\"\n\n# Update unknown slug\nPUT {{host}}/api/articles/unknown-slug-{{uid}}\nAuthorization: Token {{token}}\n{\n  \"article\": {\n    \"body\": \"test\"\n  }\n}\nHTTP 404\n[Asserts]\njsonpath \"$.errors.article[0]\" == \"not found\"\n\n# Delete unknown slug\nDELETE {{host}}/api/articles/unknown-slug-{{uid}}\nAuthorization: Token {{token}}\nHTTP 404\n[Asserts]\njsonpath \"$.errors.article[0]\" == \"not found\"\n\n# Cleanup\nDELETE {{host}}/api/articles/{{slug1}}\nAuthorization: Token {{token}}\nHTTP 204\n\nDELETE {{host}}/api/articles/{{slug2}}\nAuthorization: Token {{token}}\nHTTP 204\n"
  },
  {
    "path": "specs/api/hurl/errors_auth.hurl",
    "content": "# Register empty username\nPOST {{host}}/api/users\n{\n  \"user\": {\n    \"username\": \"\",\n    \"email\": \"ea_blank_{{uid}}@test.com\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 422\n[Asserts]\njsonpath \"$.errors.username[0]\" == \"can't be blank\"\n\n# Register empty email\nPOST {{host}}/api/users\n{\n  \"user\": {\n    \"username\": \"ea_blank_{{uid}}\",\n    \"email\": \"\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 422\n[Asserts]\njsonpath \"$.errors.email[0]\" == \"can't be blank\"\n\n# Register empty password\nPOST {{host}}/api/users\n{\n  \"user\": {\n    \"username\": \"ea_blankp_{{uid}}\",\n    \"email\": \"ea_blankp_{{uid}}@test.com\",\n    \"password\": \"\"\n  }\n}\nHTTP 422\n[Asserts]\njsonpath \"$.errors.password[0]\" == \"can't be blank\"\n\n# Register valid user for duplicate and login tests\nPOST {{host}}/api/users\n{\n  \"user\": {\n    \"username\": \"ea_dup_{{uid}}\",\n    \"email\": \"ea_dup_{{uid}}@test.com\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 201\n[Captures]\ntoken: jsonpath \"$.user.token\"\n\n# Register duplicate username\nPOST {{host}}/api/users\n{\n  \"user\": {\n    \"username\": \"ea_dup_{{uid}}\",\n    \"email\": \"ea_dup2_{{uid}}@test.com\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 409\n[Asserts]\njsonpath \"$.errors.username[0]\" == \"has already been taken\"\n\n# Register duplicate email\nPOST {{host}}/api/users\n{\n  \"user\": {\n    \"username\": \"ea_dup2_{{uid}}\",\n    \"email\": \"ea_dup_{{uid}}@test.com\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 409\n[Asserts]\njsonpath \"$.errors.email[0]\" == \"has already been taken\"\n\n# Login empty email\nPOST {{host}}/api/users/login\n{\n  \"user\": {\n    \"email\": \"\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 422\n[Asserts]\njsonpath \"$.errors.email[0]\" == \"can't be blank\"\n\n# Login empty password\nPOST {{host}}/api/users/login\n{\n  \"user\": {\n    \"email\": \"ea_dup_{{uid}}@test.com\",\n    \"password\": \"\"\n  }\n}\nHTTP 422\n[Asserts]\njsonpath \"$.errors.password[0]\" == \"can't be blank\"\n\n# Login wrong password\nPOST {{host}}/api/users/login\n{\n  \"user\": {\n    \"email\": \"ea_dup_{{uid}}@test.com\",\n    \"password\": \"wrongpassword\"\n  }\n}\nHTTP 401\n[Asserts]\njsonpath \"$.errors.credentials[0]\" == \"invalid\"\n\n# GET /user no auth\nGET {{host}}/api/user\nHTTP 401\n[Asserts]\njsonpath \"$.errors.token[0]\" == \"is missing\"\n\n# PUT /user no auth\nPUT {{host}}/api/user\n{\n  \"user\": {\n    \"bio\": \"test\"\n  }\n}\nHTTP 401\n[Asserts]\njsonpath \"$.errors.token[0]\" == \"is missing\"\n\n# Update email to empty string - should reject\nPUT {{host}}/api/user\nAuthorization: Token {{token}}\n{\n  \"user\": {\n    \"email\": \"\"\n  }\n}\nHTTP 422\n\n# Update username to empty string - should reject\nPUT {{host}}/api/user\nAuthorization: Token {{token}}\n{\n  \"user\": {\n    \"username\": \"\"\n  }\n}\nHTTP 422\n\n# Update email to null - should reject\nPUT {{host}}/api/user\nAuthorization: Token {{token}}\n{\n  \"user\": {\n    \"email\": null\n  }\n}\nHTTP 422\n\n# Update username to null - should reject\nPUT {{host}}/api/user\nAuthorization: Token {{token}}\n{\n  \"user\": {\n    \"username\": null\n  }\n}\nHTTP 422\n"
  },
  {
    "path": "specs/api/hurl/errors_authorization.hurl",
    "content": "# Register user A\nPOST {{host}}/api/users\n{\n  \"user\": {\n    \"username\": \"authz_a_{{uid}}\",\n    \"email\": \"authz_a_{{uid}}@test.com\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 201\n[Captures]\ntoken_a: jsonpath \"$.user.token\"\n\n# Register user B\nPOST {{host}}/api/users\n{\n  \"user\": {\n    \"username\": \"authz_b_{{uid}}\",\n    \"email\": \"authz_b_{{uid}}@test.com\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 201\n[Captures]\ntoken_b: jsonpath \"$.user.token\"\n\n# User A creates article\nPOST {{host}}/api/articles\nAuthorization: Token {{token_a}}\n{\n  \"article\": {\n    \"title\": \"Authz Article {{uid}}\",\n    \"description\": \"test\",\n    \"body\": \"test\"\n  }\n}\nHTTP 201\n[Captures]\nslug: jsonpath \"$.article.slug\"\n\n# User B tries to delete -> 403\nDELETE {{host}}/api/articles/{{slug}}\nAuthorization: Token {{token_b}}\nHTTP 403\n[Asserts]\njsonpath \"$.errors.article[0]\" == \"forbidden\"\n\n# User B tries to update -> 403\nPUT {{host}}/api/articles/{{slug}}\nAuthorization: Token {{token_b}}\n{\n  \"article\": {\n    \"body\": \"hijacked\"\n  }\n}\nHTTP 403\n[Asserts]\njsonpath \"$.errors.article[0]\" == \"forbidden\"\n\n# User A creates a comment on the article\nPOST {{host}}/api/articles/{{slug}}/comments\nAuthorization: Token {{token_a}}\n{\n  \"comment\": {\n    \"body\": \"A's comment\"\n  }\n}\nHTTP 201\n[Captures]\ncomment_id: jsonpath \"$.comment.id\"\n\n# User B tries to delete A's comment -> 403\nDELETE {{host}}/api/articles/{{slug}}/comments/{{comment_id}}\nAuthorization: Token {{token_b}}\nHTTP 403\n[Asserts]\njsonpath \"$.errors.comment[0]\" == \"forbidden\"\n\n# Verify comment survived the failed delete\nGET {{host}}/api/articles/{{slug}}/comments\nHTTP 200\n[Asserts]\njsonpath \"$.comments\" count >= 1\njsonpath \"$.comments[0].body\" == \"A's comment\"\n\n# Cleanup: User A deletes article\nDELETE {{host}}/api/articles/{{slug}}\nAuthorization: Token {{token_a}}\nHTTP 204\n"
  },
  {
    "path": "specs/api/hurl/errors_comments.hurl",
    "content": "# Post comment no auth\nPOST {{host}}/api/articles/some-slug/comments\n{\n  \"comment\": {\n    \"body\": \"test\"\n  }\n}\nHTTP 401\n[Asserts]\njsonpath \"$.errors.token[0]\" == \"is missing\"\n\n# Delete comment no auth\nDELETE {{host}}/api/articles/some-slug/comments/1\nHTTP 401\n[Asserts]\njsonpath \"$.errors.token[0]\" == \"is missing\"\n\n# Setup: Register + create article\nPOST {{host}}/api/users\n{\n  \"user\": {\n    \"username\": \"ec_{{uid}}\",\n    \"email\": \"ec_{{uid}}@test.com\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 201\n[Captures]\ntoken: jsonpath \"$.user.token\"\n\nPOST {{host}}/api/articles\nAuthorization: Token {{token}}\n{\n  \"article\": {\n    \"title\": \"Err Comment Art {{uid}}\",\n    \"description\": \"test\",\n    \"body\": \"test\"\n  }\n}\nHTTP 201\n[Captures]\nslug: jsonpath \"$.article.slug\"\n\n# Post comment empty body\nPOST {{host}}/api/articles/{{slug}}/comments\nAuthorization: Token {{token}}\n{\n  \"comment\": {\n    \"body\": \"\"\n  }\n}\nHTTP 422\n[Asserts]\njsonpath \"$.errors.body[0]\" == \"can't be blank\"\n\n# Post comment on unknown article\nPOST {{host}}/api/articles/unknown-slug-{{uid}}/comments\nAuthorization: Token {{token}}\n{\n  \"comment\": {\n    \"body\": \"orphan\"\n  }\n}\nHTTP 404\n[Asserts]\njsonpath \"$.errors.article[0]\" == \"not found\"\n\n# Get comments on unknown article\nGET {{host}}/api/articles/unknown-slug-{{uid}}/comments\nHTTP 404\n[Asserts]\njsonpath \"$.errors.article[0]\" == \"not found\"\n\n# Delete comment on unknown article\nDELETE {{host}}/api/articles/unknown-slug-{{uid}}/comments/99999\nAuthorization: Token {{token}}\nHTTP 404\n[Asserts]\njsonpath \"$.errors.article[0]\" == \"not found\"\n\n# Delete non-existent comment on existing article\nDELETE {{host}}/api/articles/{{slug}}/comments/99999\nAuthorization: Token {{token}}\nHTTP 404\n[Asserts]\njsonpath \"$.errors.comment[0]\" == \"not found\"\n\n# Cleanup\nDELETE {{host}}/api/articles/{{slug}}\nAuthorization: Token {{token}}\nHTTP 204\n"
  },
  {
    "path": "specs/api/hurl/errors_profiles.hurl",
    "content": "# GET unknown profile\nGET {{host}}/api/profiles/unknown-user-{{uid}}\nHTTP 404\n[Asserts]\njsonpath \"$.errors.profile[0]\" == \"not found\"\n\n# Follow no auth\nPOST {{host}}/api/profiles/unknown-user-{{uid}}/follow\nHTTP 401\n[Asserts]\njsonpath \"$.errors.token[0]\" == \"is missing\"\n\n# Unfollow no auth\nDELETE {{host}}/api/profiles/unknown-user-{{uid}}/follow\nHTTP 401\n[Asserts]\njsonpath \"$.errors.token[0]\" == \"is missing\"\n\n# Setup: Register for authenticated 404 tests\nPOST {{host}}/api/users\n{\n  \"user\": {\n    \"username\": \"ep_{{uid}}\",\n    \"email\": \"ep_{{uid}}@test.com\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 201\n[Captures]\ntoken: jsonpath \"$.user.token\"\n\n# Follow unknown user (authed)\nPOST {{host}}/api/profiles/unknown-user-{{uid}}/follow\nAuthorization: Token {{token}}\nHTTP 404\n[Asserts]\njsonpath \"$.errors.profile[0]\" == \"not found\"\n\n# Unfollow unknown user (authed)\nDELETE {{host}}/api/profiles/unknown-user-{{uid}}/follow\nAuthorization: Token {{token}}\nHTTP 404\n[Asserts]\njsonpath \"$.errors.profile[0]\" == \"not found\"\n"
  },
  {
    "path": "specs/api/hurl/favorites.hurl",
    "content": "# Setup: Register\nPOST {{host}}/api/users\n{\n  \"user\": {\n    \"username\": \"fav_{{uid}}\",\n    \"email\": \"fav_{{uid}}@test.com\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 201\n[Captures]\ntoken: jsonpath \"$.user.token\"\n\n# Setup: Create article\nPOST {{host}}/api/articles\nAuthorization: Token {{token}}\n{\n  \"article\": {\n    \"title\": \"Favorite Article {{uid}}\",\n    \"description\": \"For favorites\",\n    \"body\": \"Article body\"\n  }\n}\nHTTP 201\n[Captures]\nslug: jsonpath \"$.article.slug\"\n\n# Favorite article\nPOST {{host}}/api/articles/{{slug}}/favorite\nAuthorization: Token {{token}}\nHTTP 200\n[Asserts]\njsonpath \"$.article.title\" isString\njsonpath \"$.article.slug\" isString\njsonpath \"$.article.description\" isString\njsonpath \"$.article.body\" isString\njsonpath \"$.article.tagList\" isList\njsonpath \"$.article.createdAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.article.updatedAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.article.favorited\" == true\njsonpath \"$.article.favoritesCount\" == 1\njsonpath \"$.article.author.username\" == \"fav_{{uid}}\"\n\n# Verify favorite persists\nGET {{host}}/api/articles/{{slug}}\nAuthorization: Token {{token}}\nHTTP 200\n[Asserts]\njsonpath \"$.article.title\" isString\njsonpath \"$.article.slug\" isString\njsonpath \"$.article.description\" isString\njsonpath \"$.article.body\" isString\njsonpath \"$.article.tagList\" isList\njsonpath \"$.article.createdAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.article.updatedAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.article.favorited\" == true\njsonpath \"$.article.favoritesCount\" == 1\njsonpath \"$.article.author.username\" == \"fav_{{uid}}\"\n\n# Articles filtered by favorited username\nGET {{host}}/api/articles?favorited=fav_{{uid}}\nHTTP 200\n[Asserts]\njsonpath \"$.articles\" isList\njsonpath \"$.articlesCount\" isInteger\njsonpath \"$.articlesCount\" >= 1\njsonpath \"$.articles[0].title\" isString\njsonpath \"$.articles[0].slug\" isString\njsonpath \"$.articles[0].description\" isString\njsonpath \"$.articles[0].body\" not exists\njsonpath \"$.articles[0].tagList\" isList\njsonpath \"$.articles[0].createdAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.articles[0].updatedAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.articles[0].favorited\" isBoolean\njsonpath \"$.articles[0].favoritesCount\" isInteger\njsonpath \"$.articles[0].favoritesCount\" >= 1\n\n# Articles filtered by favorited username with auth\nGET {{host}}/api/articles?favorited=fav_{{uid}}\nAuthorization: Token {{token}}\nHTTP 200\n[Asserts]\njsonpath \"$.articles\" isList\njsonpath \"$.articlesCount\" isInteger\njsonpath \"$.articlesCount\" >= 1\njsonpath \"$.articles[0].title\" isString\njsonpath \"$.articles[0].slug\" isString\njsonpath \"$.articles[0].description\" isString\njsonpath \"$.articles[0].body\" not exists\njsonpath \"$.articles[0].tagList\" isList\njsonpath \"$.articles[0].createdAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.articles[0].updatedAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.articles[0].favorited\" isBoolean\njsonpath \"$.articles[0].favoritesCount\" isInteger\njsonpath \"$.articles[0].favoritesCount\" >= 1\n\n# Unfavorite article\nDELETE {{host}}/api/articles/{{slug}}/favorite\nAuthorization: Token {{token}}\nHTTP 200\n[Asserts]\njsonpath \"$.article.title\" isString\njsonpath \"$.article.slug\" isString\njsonpath \"$.article.description\" isString\njsonpath \"$.article.body\" isString\njsonpath \"$.article.tagList\" isList\njsonpath \"$.article.createdAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.article.updatedAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.article.favorited\" == false\njsonpath \"$.article.favoritesCount\" == 0\njsonpath \"$.article.author.username\" == \"fav_{{uid}}\"\n\n# Verify unfavorite persists\nGET {{host}}/api/articles/{{slug}}\nAuthorization: Token {{token}}\nHTTP 200\n[Asserts]\njsonpath \"$.article.title\" isString\njsonpath \"$.article.slug\" isString\njsonpath \"$.article.description\" isString\njsonpath \"$.article.body\" isString\njsonpath \"$.article.tagList\" isList\njsonpath \"$.article.createdAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.article.updatedAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.article.favorited\" == false\njsonpath \"$.article.favoritesCount\" == 0\njsonpath \"$.article.author.username\" == \"fav_{{uid}}\"\n\n# Cleanup\nDELETE {{host}}/api/articles/{{slug}}\nAuthorization: Token {{token}}\nHTTP 204\n"
  },
  {
    "path": "specs/api/hurl/feed.hurl",
    "content": "# Register main user\nPOST {{host}}/api/users\n{\n  \"user\": {\n    \"username\": \"feedm_{{uid}}\",\n    \"email\": \"feedm_{{uid}}@test.com\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 201\n[Captures]\nmain_token: jsonpath \"$.user.token\"\n\n# Register celeb user\nPOST {{host}}/api/users\n{\n  \"user\": {\n    \"username\": \"feedc_{{uid}}\",\n    \"email\": \"feedc_{{uid}}@test.com\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 201\n[Captures]\nceleb_token: jsonpath \"$.user.token\"\n\n# Feed for new user returns empty\nGET {{host}}/api/articles/feed\nAuthorization: Token {{main_token}}\nHTTP 200\n[Asserts]\njsonpath \"$.articlesCount\" == 0\njsonpath \"$.articles\" count == 0\n\n# Main follows celeb\nPOST {{host}}/api/profiles/feedc_{{uid}}/follow\nAuthorization: Token {{main_token}}\nHTTP 200\n[Asserts]\njsonpath \"$.profile.following\" == true\n\n# Celeb creates article 1\nPOST {{host}}/api/articles\nAuthorization: Token {{celeb_token}}\n{\n  \"article\": {\n    \"title\": \"Feed Article 1 {{uid}}\",\n    \"description\": \"Feed test 1\",\n    \"body\": \"Feed body 1\"\n  }\n}\nHTTP 201\n[Captures]\nslug1: jsonpath \"$.article.slug\"\n\n# Celeb creates article 2\nPOST {{host}}/api/articles\nAuthorization: Token {{celeb_token}}\n{\n  \"article\": {\n    \"title\": \"Feed Article 2 {{uid}}\",\n    \"description\": \"Feed test 2\",\n    \"body\": \"Feed body 2\"\n  }\n}\nHTTP 201\n[Captures]\nslug2: jsonpath \"$.article.slug\"\n\n# Main checks feed\nGET {{host}}/api/articles/feed\nAuthorization: Token {{main_token}}\nHTTP 200\n[Asserts]\njsonpath \"$.articles\" isList\njsonpath \"$.articlesCount\" isInteger\njsonpath \"$.articlesCount\" == 2\njsonpath \"$.articles\" count == 2\njsonpath \"$.articles[0].title\" isString\njsonpath \"$.articles[0].slug\" isString\njsonpath \"$.articles[0].description\" isString\njsonpath \"$.articles[0].body\" not exists\njsonpath \"$.articles[0].tagList\" isList\njsonpath \"$.articles[0].createdAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.articles[0].updatedAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.articles[0].favorited\" isBoolean\njsonpath \"$.articles[0].favoritesCount\" isInteger\njsonpath \"$.articles[0].author.username\" == \"feedc_{{uid}}\"\n\n# Feed with limit=1\nGET {{host}}/api/articles/feed?limit=1\nAuthorization: Token {{main_token}}\nHTTP 200\n[Asserts]\njsonpath \"$.articles\" count == 1\njsonpath \"$.articlesCount\" == 2\njsonpath \"$.articles[0].title\" isString\njsonpath \"$.articles[0].slug\" isString\njsonpath \"$.articles[0].description\" isString\njsonpath \"$.articles[0].body\" not exists\njsonpath \"$.articles[0].tagList\" isList\njsonpath \"$.articles[0].createdAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.articles[0].updatedAt\" matches \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}\"\njsonpath \"$.articles[0].favorited\" isBoolean\njsonpath \"$.articles[0].favoritesCount\" isInteger\njsonpath \"$.articles[0].author.username\" == \"feedc_{{uid}}\"\njsonpath \"$.articles[0].author.following\" == true\n\n# Feed with limit=1&offset=1\nGET {{host}}/api/articles/feed?limit=1&offset=1\nAuthorization: Token {{main_token}}\nHTTP 200\n[Asserts]\njsonpath \"$.articles\" count == 1\njsonpath \"$.articlesCount\" == 2\n\n# Cleanup: delete articles\nDELETE {{host}}/api/articles/{{slug1}}\nAuthorization: Token {{celeb_token}}\nHTTP 204\n\nDELETE {{host}}/api/articles/{{slug2}}\nAuthorization: Token {{celeb_token}}\nHTTP 204\n\n# Cleanup: unfollow\nDELETE {{host}}/api/profiles/feedc_{{uid}}/follow\nAuthorization: Token {{main_token}}\nHTTP 200\n"
  },
  {
    "path": "specs/api/hurl/pagination.hurl",
    "content": "# Setup: Register\nPOST {{host}}/api/users\n{\n  \"user\": {\n    \"username\": \"page_{{uid}}\",\n    \"email\": \"page_{{uid}}@test.com\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 201\n[Captures]\ntoken: jsonpath \"$.user.token\"\n\n# Create article 1\nPOST {{host}}/api/articles\nAuthorization: Token {{token}}\n{\n  \"article\": {\n    \"title\": \"Pagination 1 {{uid}}\",\n    \"description\": \"Page test 1\",\n    \"body\": \"Page body 1\"\n  }\n}\nHTTP 201\n[Captures]\nslug1: jsonpath \"$.article.slug\"\n\n# Create article 2\nPOST {{host}}/api/articles\nAuthorization: Token {{token}}\n{\n  \"article\": {\n    \"title\": \"Pagination 2 {{uid}}\",\n    \"description\": \"Page test 2\",\n    \"body\": \"Page body 2\"\n  }\n}\nHTTP 201\n[Asserts]\njsonpath \"$.article.title\" == \"Pagination 2 {{uid}}\"\njsonpath \"$.article.slug\" isString\n[Captures]\nslug2: jsonpath \"$.article.slug\"\n\n# List with limit=1 (most recent first, so slug2)\nGET {{host}}/api/articles?author=page_{{uid}}&limit=1\nHTTP 200\n[Asserts]\njsonpath \"$.articles\" count == 1\njsonpath \"$.articlesCount\" == 2\njsonpath \"$.articles[0].slug\" == \"{{slug2}}\"\n\n# List with limit=1&offset=1 (second page, so slug1)\nGET {{host}}/api/articles?author=page_{{uid}}&limit=1&offset=1\nHTTP 200\n[Asserts]\njsonpath \"$.articles\" count == 1\njsonpath \"$.articlesCount\" == 2\njsonpath \"$.articles[0].slug\" == \"{{slug1}}\"\n\n# Cleanup\nDELETE {{host}}/api/articles/{{slug1}}\nAuthorization: Token {{token}}\nHTTP 204\n\nDELETE {{host}}/api/articles/{{slug2}}\nAuthorization: Token {{token}}\nHTTP 204\n"
  },
  {
    "path": "specs/api/hurl/profiles.hurl",
    "content": "# Register main user\nPOST {{host}}/api/users\n{\n  \"user\": {\n    \"username\": \"prof_{{uid}}\",\n    \"email\": \"prof_{{uid}}@test.com\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 201\n[Captures]\ntoken: jsonpath \"$.user.token\"\n\n# Register celeb user\nPOST {{host}}/api/users\n{\n  \"user\": {\n    \"username\": \"celeb_{{uid}}\",\n    \"email\": \"celeb_{{uid}}@test.com\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 201\n\n# Get profile without auth\nGET {{host}}/api/profiles/celeb_{{uid}}\nHTTP 200\n[Asserts]\njsonpath \"$.profile.username\" == \"celeb_{{uid}}\"\njsonpath \"$.profile.bio\" == null\njsonpath \"$.profile.image\" == null\njsonpath \"$.profile.following\" == false\n\n# Get profile with auth\nGET {{host}}/api/profiles/celeb_{{uid}}\nAuthorization: Token {{token}}\nHTTP 200\n[Asserts]\njsonpath \"$.profile.username\" == \"celeb_{{uid}}\"\njsonpath \"$.profile.bio\" == null\njsonpath \"$.profile.image\" == null\njsonpath \"$.profile.following\" == false\n\n# Follow profile\nPOST {{host}}/api/profiles/celeb_{{uid}}/follow\nAuthorization: Token {{token}}\nHTTP 200\n[Asserts]\njsonpath \"$.profile.username\" == \"celeb_{{uid}}\"\njsonpath \"$.profile.bio\" == null\njsonpath \"$.profile.image\" == null\njsonpath \"$.profile.following\" == true\n\n# Unfollow profile\nDELETE {{host}}/api/profiles/celeb_{{uid}}/follow\nAuthorization: Token {{token}}\nHTTP 200\n[Asserts]\njsonpath \"$.profile.username\" == \"celeb_{{uid}}\"\njsonpath \"$.profile.bio\" == null\njsonpath \"$.profile.image\" == null\njsonpath \"$.profile.following\" == false\n\n# Verify unfollow persisted\nGET {{host}}/api/profiles/celeb_{{uid}}\nAuthorization: Token {{token}}\nHTTP 200\n[Asserts]\njsonpath \"$.profile.username\" == \"celeb_{{uid}}\"\njsonpath \"$.profile.bio\" == null\njsonpath \"$.profile.image\" == null\njsonpath \"$.profile.following\" == false\n"
  },
  {
    "path": "specs/api/hurl/run-hurl-tests.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nDIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nHOST=\"${HOST:-http://localhost:8000}\"\nUID_VAL=\"${UID_VAL:-$(date +%s)$$}\"\n\necho \"Running Hurl tests against $HOST with uid=$UID_VAL\"\n\nFILES=(\"$@\")\nif [ ${#FILES[@]} -eq 0 ]; then\n  FILES=(\"$DIR\"/*.hurl)\nfi\n\nhurl --test \\\n  --jobs 1 \\\n  --variable \"host=$HOST\" \\\n  --variable \"uid=$UID_VAL\" \\\n  \"${FILES[@]}\"\n"
  },
  {
    "path": "specs/api/hurl/tags.hurl",
    "content": "# Setup: Register\nPOST {{host}}/api/users\n{\n  \"user\": {\n    \"username\": \"tag_{{uid}}\",\n    \"email\": \"tag_{{uid}}@test.com\",\n    \"password\": \"password123\"\n  }\n}\nHTTP 201\n[Captures]\ntoken: jsonpath \"$.user.token\"\n\n# Setup: Create article with tags\nPOST {{host}}/api/articles\nAuthorization: Token {{token}}\n{\n  \"article\": {\n    \"title\": \"Tag Article {{uid}}\",\n    \"description\": \"For tags\",\n    \"body\": \"Article body\",\n    \"tagList\": [\"h_{{uid}}\", \"t_{{uid}}\"]\n  }\n}\nHTTP 201\n[Captures]\nslug: jsonpath \"$.article.slug\"\n\n# Get tags\nGET {{host}}/api/tags\nHTTP 200\n[Asserts]\njsonpath \"$.tags\" isList\njsonpath \"$.tags\" count >= 1\njsonpath \"$.tags\" contains \"h_{{uid}}\"\njsonpath \"$.tags\" contains \"t_{{uid}}\"\njsonpath \"$.tags[0]\" isString\njsonpath \"$.tags[1]\" isString\n\n# Cleanup\nDELETE {{host}}/api/articles/{{slug}}\nAuthorization: Token {{token}}\nHTTP 204\n"
  },
  {
    "path": "specs/api/hurl-to-bruno.js",
    "content": "#!/usr/bin/env bun\n// Converts Hurl test files in api/hurl/ to a Bruno collection in api/bruno/\n// Usage: bun api/hurl-to-bruno.js [--check]\n\nimport { readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from \"node:fs\";\nimport { join, basename, resolve } from \"node:path\";\nimport { tmpdir } from \"node:os\";\n\nconst ROOT = resolve(import.meta.dirname);\nconst HURL_DIR = join(ROOT, \"hurl\");\nconst BRUNO_DIR = join(ROOT, \"bruno\");\nconst CHECK_MODE = process.argv.includes(\"--check\");\n\n// ─── Parse ──────────────────────────────────────────────────────────────────\n\nfunction parseHurlFile(filePath) {\n  const lines = readFileSync(filePath, \"utf-8\").split(\"\\n\");\n  const requests = [];\n  let current = null;\n  let state = \"IDLE\";\n  let bodyLines = [];\n  let braceDepth = 0;\n\n  function finishBody() {\n    if (current && bodyLines.length > 0) {\n      current.body = bodyLines.join(\"\\n\");\n      bodyLines = [];\n      braceDepth = 0;\n    }\n  }\n\n  function pushCurrent() {\n    if (current) {\n      finishBody();\n      if (current.method) {\n        requests.push(current);\n      }\n      current = null;\n    }\n  }\n\n  for (let i = 0; i < lines.length; i++) {\n    const line = lines[i];\n    const trimmed = line.trim();\n\n    // Blank line\n    if (trimmed === \"\") {\n      if (state === \"BODY\") {\n        // blank lines inside JSON are kept\n        bodyLines.push(line);\n      }\n      continue;\n    }\n\n    // Comment line — starts a new request logically (captured for naming)\n    if (trimmed.startsWith(\"#\") && state !== \"BODY\") {\n      pushCurrent();\n      current = {\n        comment: trimmed.replace(/^#\\s*/, \"\"),\n        method: null,\n        url: null,\n        headers: {},\n        body: null,\n        statusCode: null,\n        asserts: [],\n        captures: [],\n      };\n      state = \"IDLE\";\n      continue;\n    }\n\n    // Method line (GET, POST, PUT, DELETE, PATCH)\n    const methodMatch = trimmed.match(/^(GET|POST|PUT|DELETE|PATCH)\\s+(.+)$/);\n    if (methodMatch && state !== \"BODY\") {\n      if (current && current.method) {\n        // Back-to-back request: push completed request, start new without comment\n        pushCurrent();\n        current = {\n          comment: null,\n          method: null,\n          url: null,\n          headers: {},\n          body: null,\n          statusCode: null,\n          asserts: [],\n          captures: [],\n        };\n      } else if (!current) {\n        current = {\n          comment: null,\n          method: null,\n          url: null,\n          headers: {},\n          body: null,\n          statusCode: null,\n          asserts: [],\n          captures: [],\n        };\n      }\n      current.method = methodMatch[1];\n      current.url = methodMatch[2];\n      state = \"HEADERS\";\n      continue;\n    }\n\n    // Header line (Key: Value) — only when in HEADERS state\n    if (state === \"HEADERS\") {\n      const headerMatch = trimmed.match(/^([A-Za-z][\\w-]*)\\s*:\\s*(.+)$/);\n      if (headerMatch) {\n        current.headers[headerMatch[1]] = headerMatch[2];\n        continue;\n      }\n    }\n\n    // Start of JSON body\n    if (trimmed === \"{\" && (state === \"HEADERS\" || state === \"IDLE\")) {\n      state = \"BODY\";\n      bodyLines = [line];\n      braceDepth = 1;\n      continue;\n    }\n\n    // Inside JSON body\n    if (state === \"BODY\") {\n      bodyLines.push(line);\n      for (const ch of trimmed) {\n        if (ch === \"{\") braceDepth++;\n        else if (ch === \"}\") braceDepth--;\n      }\n      if (braceDepth === 0) {\n        finishBody();\n        state = \"IDLE\";\n      }\n      continue;\n    }\n\n    // HTTP status line\n    const statusMatch = trimmed.match(/^HTTP\\s+(\\d+)$/);\n    if (statusMatch) {\n      current.statusCode = parseInt(statusMatch[1], 10);\n      state = \"RESPONSE\";\n      continue;\n    }\n\n    // Sections\n    if (trimmed === \"[Asserts]\") {\n      state = \"ASSERTS\";\n      continue;\n    }\n    if (trimmed === \"[Captures]\") {\n      state = \"CAPTURES\";\n      continue;\n    }\n\n    // Captures\n    if (state === \"CAPTURES\") {\n      const captureMatch = trimmed.match(/^(\\w+)\\s*:\\s*jsonpath\\s+\"(.+)\"$/);\n      if (captureMatch) {\n        current.captures.push({ name: captureMatch[1], jsonpath: captureMatch[2] });\n      }\n      continue;\n    }\n\n    // Asserts\n    if (state === \"ASSERTS\") {\n      current.asserts.push(trimmed);\n      continue;\n    }\n  }\n\n  pushCurrent();\n  return requests;\n}\n\n// ─── Transform ──────────────────────────────────────────────────────────────\n\nfunction slugify(str) {\n  return str\n    .toLowerCase()\n    .replace(/[^a-z0-9]+/g, \"-\")\n    .replace(/^-|-$/g, \"\");\n}\n\nfunction folderName(filename) {\n  return basename(filename, \".hurl\").replace(/_/g, \"-\");\n}\n\nfunction fileName(request, index, method, url) {\n  const nn = String(index + 1).padStart(2, \"0\");\n  let slug;\n  if (request.comment) {\n    slug = slugify(request.comment);\n  } else {\n    // derive from method + last path segment\n    const pathEnd = url.split(\"/\").pop().split(\"?\")[0] || \"request\";\n    slug = slugify(`${method}-${pathEnd}`);\n  }\n  return `${nn}-${slug}.bru`;\n}\n\nfunction jsonpathToJs(jp) {\n  // Convert jsonpath like \"$.user.username\" to \"res.body.user.username\"\n  let path = jp;\n  if (path.startsWith(\"$.\")) {\n    path = path.slice(2);\n  }\n  return `res.body.${path}`;\n}\n\nfunction jsonpathToPropertyCheck(jp) {\n  // For \"not exists\" checks: split into parent path and property name\n  // e.g. \"$.articles[0].body\" -> { parent: \"res.body.articles[0]\", prop: \"body\" }\n  let path = jp;\n  if (path.startsWith(\"$.\")) {\n    path = path.slice(2);\n  }\n  const lastDot = path.lastIndexOf(\".\");\n  if (lastDot === -1) {\n    return { parent: \"res.body\", prop: path };\n  }\n  return {\n    parent: `res.body.${path.slice(0, lastDot)}`,\n    prop: path.slice(lastDot + 1),\n  };\n}\n\nfunction transformValue(rawValue) {\n  // Handle template variables in values\n  // \"auth_{{uid}}\" -> \"auth_\" + bru.getVar(\"uid\")\n  // \"{{slug}}\" -> bru.getVar(\"slug\")\n  // {{comment_id}} (bare var) -> bru.getVar(\"comment_id\")\n\n  if (rawValue === \"null\") return { expr: \"null\", isNull: true };\n  if (rawValue === \"true\") return { expr: \"true\", isLiteral: true };\n  if (rawValue === \"false\") return { expr: \"false\", isLiteral: true };\n  if (/^-?\\d+$/.test(rawValue)) return { expr: rawValue, isLiteral: true };\n  if (/^-?\\d+\\.\\d+$/.test(rawValue)) return { expr: rawValue, isLiteral: true };\n\n  // Bare variable like {{comment_id}}\n  const bareVarMatch = rawValue.match(/^\\{\\{(\\w+)\\}\\}$/);\n  if (bareVarMatch) {\n    return { expr: `bru.getVar(\"${bareVarMatch[1]}\")`, isLiteral: true };\n  }\n\n  // Quoted string\n  if (rawValue.startsWith('\"') && rawValue.endsWith('\"')) {\n    const inner = rawValue.slice(1, -1);\n\n    // Check if it's purely a variable \"{{slug}}\"\n    const pureVarMatch = inner.match(/^\\{\\{(\\w+)\\}\\}$/);\n    if (pureVarMatch) {\n      return { expr: `bru.getVar(\"${pureVarMatch[1]}\")`, isLiteral: true };\n    }\n\n    // Check if it contains template variables mixed with text\n    if (inner.includes(\"{{\")) {\n      // Split by template vars and build concatenation\n      const parts = [];\n      let remaining = inner;\n      while (remaining.length > 0) {\n        const varIdx = remaining.indexOf(\"{{\");\n        if (varIdx === -1) {\n          parts.push(`\"${remaining}\"`);\n          break;\n        }\n        if (varIdx > 0) {\n          parts.push(`\"${remaining.slice(0, varIdx)}\"`);\n        }\n        const endIdx = remaining.indexOf(\"}}\", varIdx);\n        const varName = remaining.slice(varIdx + 2, endIdx);\n        parts.push(`bru.getVar(\"${varName}\")`);\n        remaining = remaining.slice(endIdx + 2);\n      }\n      return { expr: parts.join(\" + \"), isLiteral: true };\n    }\n\n    // Plain quoted string\n    return { expr: rawValue, isLiteral: true };\n  }\n\n  return { expr: rawValue, isLiteral: true };\n}\n\nfunction assertToJs(assertLine) {\n  // jsonpath \"$.x.y\" == \"val\"\n  // jsonpath \"$.x\" isString\n  // jsonpath \"$.x\" count == 1\n  // jsonpath \"$.x\" contains \"val\"\n  // jsonpath \"$.x\" not isEmpty\n  // jsonpath \"$.x\" not exists\n  // jsonpath \"$.x\" matches \"regex\"\n\n  const jpMatch = assertLine.match(/^jsonpath\\s+\"([^\"]+)\"\\s+(.+)$/);\n  if (!jpMatch) throw new Error(`Unhandled hurl assert (not jsonpath): ${assertLine}`);\n\n  const jp = jpMatch[1];\n  const rest = jpMatch[2].trim();\n\n  // count == N or count >= N\n  const countMatch = rest.match(/^count\\s+(==|>=)\\s+(.+)$/);\n  if (countMatch) {\n    const op = countMatch[1];\n    const val = countMatch[2].trim();\n    const jsPath = jsonpathToJs(jp);\n    const transformed = transformValue(val);\n    if (op === \"==\") {\n      return `expect(${jsPath}.length).to.eql(${transformed.expr});`;\n    } else if (op === \">=\") {\n      return `expect(${jsPath}.length).to.be.at.least(${transformed.expr});`;\n    }\n  }\n\n  // not exists\n  if (rest === \"not exists\") {\n    const { parent, prop } = jsonpathToPropertyCheck(jp);\n    return `expect(${parent}).to.not.have.property(\"${prop}\");`;\n  }\n\n  // not isEmpty\n  if (rest === \"not isEmpty\") {\n    const jsPath = jsonpathToJs(jp);\n    return `expect(${jsPath}).to.not.eql(\"\");`;\n  }\n\n  // isString\n  if (rest === \"isString\") {\n    const jsPath = jsonpathToJs(jp);\n    return `expect(typeof ${jsPath}).to.eql(\"string\");`;\n  }\n\n  // isInteger\n  if (rest === \"isInteger\") {\n    const jsPath = jsonpathToJs(jp);\n    return `expect(Number.isInteger(${jsPath})).to.eql(true);`;\n  }\n\n  // isCollection (matches both arrays and objects)\n  if (rest === \"isCollection\") {\n    const jsPath = jsonpathToJs(jp);\n    return `expect(${jsPath}).to.not.be.null; expect(typeof ${jsPath}).to.eql(\"object\");`;\n  }\n\n  // isList (arrays only)\n  if (rest === \"isList\") {\n    const jsPath = jsonpathToJs(jp);\n    return `expect(Array.isArray(${jsPath})).to.eql(true);`;\n  }\n\n  // isObject (objects only, not arrays)\n  if (rest === \"isObject\") {\n    const jsPath = jsonpathToJs(jp);\n    return `expect(${jsPath}).to.not.be.null; expect(typeof ${jsPath}).to.eql(\"object\"); expect(Array.isArray(${jsPath})).to.eql(false);`;\n  }\n\n  // isBoolean\n  if (rest === \"isBoolean\") {\n    const jsPath = jsonpathToJs(jp);\n    return `expect(typeof ${jsPath}).to.eql(\"boolean\");`;\n  }\n\n  // contains \"val\" or contains bareVar\n  const containsMatch = rest.match(/^contains\\s+(.+)$/);\n  if (containsMatch) {\n    const jsPath = jsonpathToJs(jp);\n    const rawVal = containsMatch[1].trim();\n    const val = transformValue(rawVal);\n    return `expect(${jsPath}).to.include(${val.expr});`;\n  }\n\n  // matches \"regex\"\n  const matchesMatch = rest.match(/^matches\\s+\"([^\"]*)\"$/);\n  if (matchesMatch) {\n    const jsPath = jsonpathToJs(jp);\n    return `expect(${jsPath}).to.match(/${matchesMatch[1]}/);`;\n  }\n\n  // ==, !=, or >= with value\n  const opMatch = rest.match(/^(==|!=|>=)\\s+(.+)$/);\n  if (opMatch) {\n    const op = opMatch[1];\n    const rawVal = opMatch[2].trim();\n    const jsPath = jsonpathToJs(jp);\n    const val = transformValue(rawVal);\n\n    if (op === \"==\") {\n      if (val.isNull) {\n        return `expect(${jsPath}).to.be.null;`;\n      }\n      return `expect(${jsPath}).to.eql(${val.expr});`;\n    } else if (op === \"!=\") {\n      if (val.isNull) {\n        return `expect(${jsPath}).to.not.be.null;`;\n      }\n      return `expect(${jsPath}).to.not.eql(${val.expr});`;\n    } else if (op === \">=\") {\n      return `expect(${jsPath}).to.be.at.least(${val.expr});`;\n    }\n  }\n\n  throw new Error(`Unhandled hurl assert: ${assertLine}`);\n}\n\n// ─── Generate ───────────────────────────────────────────────────────────────\n\nfunction generateBruFile(request, seq) {\n  const sections = [];\n\n  // Meta\n  const name = request.comment || `${request.method} ${request.url.split(\"/\").pop()}`;\n  sections.push(`meta {\n  name: ${name}\n  type: http\n  seq: ${seq}\n}`);\n\n  // Request\n  const hasBody = request.body !== null;\n  const bodyType = hasBody ? \"json\" : \"none\";\n  sections.push(`${request.method.toLowerCase()} {\n  url: ${request.url}\n  body: ${bodyType}\n  auth: none\n}`);\n\n  // Headers\n  const headerEntries = Object.entries(request.headers);\n  if (headerEntries.length > 0) {\n    const headerLines = headerEntries.map(([k, v]) => `  ${k}: ${v}`).join(\"\\n\");\n    sections.push(`headers {\\n${headerLines}\\n}`);\n  }\n\n  // Body\n  // Indent JSON body by 2 spaces so that closing braces don't appear at column 1,\n  // which the .bru parser would mistake for the block-closing brace.\n  if (hasBody) {\n    const indentedBody = request.body.split(\"\\n\").map((l) => (l.trim() ? \"  \" + l : l)).join(\"\\n\");\n    sections.push(`body:json {\\n${indentedBody}\\n}`);\n  }\n\n  // Assert (status code)\n  if (request.statusCode !== null) {\n    sections.push(`assert {\\n  res.status: eq ${request.statusCode}\\n}`);\n  }\n\n  // Script: post-response (captures + assertions)\n  const scriptLines = [];\n\n  for (const capture of request.captures) {\n    // Convert jsonpath to JS dotpath\n    let dotpath = capture.jsonpath;\n    if (dotpath.startsWith(\"$.\")) dotpath = dotpath.slice(2);\n    scriptLines.push(`bru.setVar(\"${capture.name}\", res.body.${dotpath});`);\n  }\n\n  for (const assertLine of request.asserts) {\n    scriptLines.push(assertToJs(assertLine));\n  }\n\n  if (scriptLines.length > 0) {\n    const body = scriptLines.map((l) => `  ${l}`).join(\"\\n\");\n    sections.push(`script:post-response {\\n${body}\\n}`);\n  }\n\n  return sections.join(\"\\n\\n\") + \"\\n\";\n}\n\nfunction generateCollection(outputDir) {\n  mkdirSync(outputDir, { recursive: true });\n\n  // bruno.json\n  writeFileSync(\n    join(outputDir, \"bruno.json\"),\n    JSON.stringify(\n      {\n        version: \"1\",\n        name: \"RealWorld API\",\n        type: \"collection\",\n      },\n      null,\n      2\n    ) + \"\\n\"\n  );\n\n  // collection.bru — collection-level pre-request\n  writeFileSync(\n    join(outputDir, \"collection.bru\"),\n    `script:pre-request {\n  if (!bru.getVar(\"uid\")) {\n    bru.setVar(\"uid\", Date.now().toString() + Math.random().toString(36).substring(2, 6));\n  }\n}\n`\n  );\n\n  // environments/local.bru\n  const envDir = join(outputDir, \"environments\");\n  mkdirSync(envDir, { recursive: true });\n  writeFileSync(\n    join(envDir, \"local.bru\"),\n    `vars {\n  host: http://localhost:3000\n}\n`\n  );\n\n  // Process each hurl file\n  const hurlFiles = readdirSync(HURL_DIR)\n    .filter((f) => f.endsWith(\".hurl\"))\n    .sort();\n\n  for (const hurlFile of hurlFiles) {\n    const filePath = join(HURL_DIR, hurlFile);\n    const requests = parseHurlFile(filePath);\n    const folder = folderName(hurlFile);\n    const folderPath = join(outputDir, folder);\n    mkdirSync(folderPath, { recursive: true });\n\n    for (let i = 0; i < requests.length; i++) {\n      const req = requests[i];\n      const bruFileName = fileName(req, i, req.method, req.url);\n      const bruContent = generateBruFile(req, i + 1);\n      writeFileSync(join(folderPath, bruFileName), bruContent);\n    }\n  }\n}\n\n// ─── Check Mode ─────────────────────────────────────────────────────────────\n\nfunction collectFiles(dir, prefix = \"\") {\n  const result = {};\n  if (!existsSync(dir)) return result;\n\n  const entries = readdirSync(dir, { withFileTypes: true });\n  for (const entry of entries) {\n    const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;\n    if (entry.isDirectory()) {\n      Object.assign(result, collectFiles(join(dir, entry.name), relPath));\n    } else {\n      result[relPath] = readFileSync(join(dir, entry.name), \"utf-8\");\n    }\n  }\n  return result;\n}\n\nfunction checkMode() {\n  const tempDir = join(tmpdir(), `bruno-check-${Date.now()}`);\n  try {\n    generateCollection(tempDir);\n    const expected = collectFiles(tempDir);\n    const actual = collectFiles(BRUNO_DIR);\n\n    const allKeys = new Set([...Object.keys(expected), ...Object.keys(actual)]);\n    const diffs = [];\n\n    for (const key of [...allKeys].sort()) {\n      if (!(key in actual)) {\n        diffs.push(`  missing: ${key}`);\n      } else if (!(key in expected)) {\n        diffs.push(`  extra:   ${key}`);\n      } else if (expected[key] !== actual[key]) {\n        diffs.push(`  changed: ${key}`);\n      }\n    }\n\n    if (diffs.length > 0) {\n      console.error(\"Bruno collection is out of sync with Hurl files:\\n\" + diffs.join(\"\\n\"));\n      console.error(\"\\nRun `bun api/hurl-to-bruno.js` to regenerate.\");\n      process.exit(1);\n    }\n\n    console.log(\"Bruno collection is up to date.\");\n  } finally {\n    rmSync(tempDir, { recursive: true, force: true });\n  }\n}\n\n// ─── Main ───────────────────────────────────────────────────────────────────\n\nif (CHECK_MODE) {\n  checkMode();\n} else {\n  // Clean and regenerate\n  if (existsSync(BRUNO_DIR)) {\n    rmSync(BRUNO_DIR, { recursive: true });\n  }\n  generateCollection(BRUNO_DIR);\n\n  // Count generated files\n  const files = collectFiles(BRUNO_DIR);\n  console.log(`Generated ${Object.keys(files).length} files in api/bruno/`);\n}\n"
  },
  {
    "path": "specs/api/openapi.yml",
    "content": "openapi: 3.1.0\ninfo:\n  title: RealWorld Conduit API\n  description: Conduit API documentation\n  contact:\n    name: RealWorld\n    url: https://realworld-docs.netlify.app/\n  license:\n    name: MIT License\n    url: https://opensource.org/licenses/MIT\n  version: 2.0.0\ntags:\n  - name: Articles\n  - name: Comments\n  - name: Favorites\n  - name: Profile\n  - name: Tags\n  - name: User and Authentication\nservers:\n  - url: https://api.realworld.show/api\npaths:\n  /users/login:\n    post:\n      tags:\n        - User and Authentication\n      summary: Existing user login\n      description: Login for existing user\n      operationId: Login\n      requestBody:\n        $ref: '#/components/requestBodies/LoginUserRequest'\n      responses:\n        '200':\n          $ref: '#/components/responses/UserResponse'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '422':\n          $ref: '#/components/responses/GenericError'\n      x-codegen-request-body-name: body\n  /users:\n    post:\n      tags:\n        - User and Authentication\n      description: Register a new user\n      operationId: CreateUser\n      requestBody:\n        $ref: '#/components/requestBodies/NewUserRequest'\n      responses:\n        '201':\n          $ref: '#/components/responses/UserResponse'\n        '409':\n          $ref: '#/components/responses/ConflictError'\n        '422':\n          $ref: '#/components/responses/GenericError'\n      x-codegen-request-body-name: body\n  /user:\n    get:\n      tags:\n        - User and Authentication\n      summary: Get current user\n      description: Gets the currently logged-in user\n      operationId: GetCurrentUser\n      responses:\n        '200':\n          $ref: '#/components/responses/UserResponse'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '422':\n          $ref: '#/components/responses/GenericError'\n      security:\n        - Token: [ ]\n    put:\n      tags:\n        - User and Authentication\n      summary: Update current user\n      description: Updated user information for current user\n      operationId: UpdateCurrentUser\n      requestBody:\n        $ref: '#/components/requestBodies/UpdateUserRequest'\n      responses:\n        '200':\n          $ref: '#/components/responses/UserResponse'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '422':\n          $ref: '#/components/responses/GenericError'\n      security:\n        - Token: [ ]\n      x-codegen-request-body-name: body\n  /profiles/{username}:\n    get:\n      tags:\n        - Profile\n      summary: Get a profile\n      description: Get a profile of a user of the system. Auth is optional\n      operationId: GetProfileByUsername\n      parameters:\n        - name: username\n          in: path\n          description: Username of the profile to get\n          required: true\n          schema:\n            type: string\n      responses:\n        '200':\n          $ref: '#/components/responses/ProfileResponse'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '404':\n          $ref: '#/components/responses/NotFound'\n        '422':\n          $ref: '#/components/responses/GenericError'\n  /profiles/{username}/follow:\n    post:\n      tags:\n        - Profile\n      summary: Follow a user\n      description: Follow a user by username\n      operationId: FollowUserByUsername\n      parameters:\n        - name: username\n          in: path\n          description: Username of the profile you want to follow\n          required: true\n          schema:\n            type: string\n      responses:\n        '200':\n          $ref: '#/components/responses/ProfileResponse'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '404':\n          $ref: '#/components/responses/NotFound'\n        '422':\n          $ref: '#/components/responses/GenericError'\n      security:\n        - Token: [ ]\n    delete:\n      tags:\n        - Profile\n      summary: Unfollow a user\n      description: Unfollow a user by username\n      operationId: UnfollowUserByUsername\n      parameters:\n        - name: username\n          in: path\n          description: Username of the profile you want to unfollow\n          required: true\n          schema:\n            type: string\n      responses:\n        '200':\n          $ref: '#/components/responses/ProfileResponse'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '404':\n          $ref: '#/components/responses/NotFound'\n        '422':\n          $ref: '#/components/responses/GenericError'\n      security:\n        - Token: [ ]\n  /articles/feed:\n    get:\n      tags:\n        - Articles\n      summary: Get recent articles from users you follow\n      description: Get most recent articles from users you follow. Use query parameters\n        to limit. Auth is required\n      operationId: GetArticlesFeed\n      parameters:\n        - $ref: '#/components/parameters/offsetParam'\n        - $ref: '#/components/parameters/limitParam'\n      responses:\n        '200':\n          $ref: '#/components/responses/MultipleArticlesResponse'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '422':\n          $ref: '#/components/responses/GenericError'\n      security:\n        - Token: [ ]\n  /articles:\n    get:\n      tags:\n        - Articles\n      summary: Get recent articles globally\n      description: Get most recent articles globally. Use query parameters to filter\n        results. Auth is optional\n      operationId: GetArticles\n      parameters:\n        - name: tag\n          in: query\n          description: Filter by tag\n          schema:\n            type: string\n        - name: author\n          in: query\n          description: Filter by author (username)\n          schema:\n            type: string\n        - name: favorited\n          in: query\n          description: Filter by favorites of a user (username)\n          schema:\n            type: string\n        - $ref: '#/components/parameters/offsetParam'\n        - $ref: '#/components/parameters/limitParam'\n      responses:\n        '200':\n          $ref: '#/components/responses/MultipleArticlesResponse'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '422':\n          $ref: '#/components/responses/GenericError'\n    post:\n      tags:\n        - Articles\n      summary: Create an article\n      description: Create an article. Auth is required\n      operationId: CreateArticle\n      requestBody:\n        $ref: '#/components/requestBodies/NewArticleRequest'\n      responses:\n        '201':\n          $ref: '#/components/responses/SingleArticleResponse'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '409':\n          $ref: '#/components/responses/ConflictError'\n        '422':\n          $ref: '#/components/responses/GenericError'\n      security:\n        - Token: [ ]\n      x-codegen-request-body-name: article\n  /articles/{slug}:\n    get:\n      tags:\n        - Articles\n      summary: Get an article\n      description: Get an article. Auth not required\n      operationId: GetArticle\n      parameters:\n        - name: slug\n          in: path\n          description: Slug of the article to get\n          required: true\n          schema:\n            type: string\n      responses:\n        '200':\n          $ref: '#/components/responses/SingleArticleResponse'\n        '404':\n          $ref: '#/components/responses/NotFound'\n        '422':\n          $ref: '#/components/responses/GenericError'\n    put:\n      tags:\n        - Articles\n      summary: Update an article\n      description: Update an article. Auth is required\n      operationId: UpdateArticle\n      parameters:\n        - name: slug\n          in: path\n          description: Slug of the article to update\n          required: true\n          schema:\n            type: string\n      requestBody:\n        $ref: '#/components/requestBodies/UpdateArticleRequest'\n      responses:\n        '200':\n          $ref: '#/components/responses/SingleArticleResponse'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '403':\n          $ref: '#/components/responses/Forbidden'\n        '404':\n          $ref: '#/components/responses/NotFound'\n        '422':\n          $ref: '#/components/responses/GenericError'\n      security:\n        - Token: [ ]\n      x-codegen-request-body-name: article\n    delete:\n      tags:\n        - Articles\n      summary: Delete an article\n      description: Delete an article. Auth is required\n      operationId: DeleteArticle\n      parameters:\n        - name: slug\n          in: path\n          description: Slug of the article to delete\n          required: true\n          schema:\n            type: string\n      responses:\n        '204':\n          $ref: '#/components/responses/EmptyOkResponse'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '403':\n          $ref: '#/components/responses/Forbidden'\n        '404':\n          $ref: '#/components/responses/NotFound'\n        '422':\n          $ref: '#/components/responses/GenericError'\n      security:\n        - Token: [ ]\n  /articles/{slug}/comments:\n    get:\n      tags:\n        - Comments\n      summary: Get comments for an article\n      description: Get the comments for an article. Auth is optional\n      operationId: GetArticleComments\n      parameters:\n        - name: slug\n          in: path\n          description: Slug of the article that you want to get comments for\n          required: true\n          schema:\n            type: string\n      responses:\n        '200':\n          $ref: '#/components/responses/MultipleCommentsResponse'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '404':\n          $ref: '#/components/responses/NotFound'\n        '422':\n          $ref: '#/components/responses/GenericError'\n    post:\n      tags:\n        - Comments\n      summary: Create a comment for an article\n      description: Create a comment for an article. Auth is required\n      operationId: CreateArticleComment\n      parameters:\n        - name: slug\n          in: path\n          description: Slug of the article that you want to create a comment for\n          required: true\n          schema:\n            type: string\n      requestBody:\n        $ref: '#/components/requestBodies/NewCommentRequest'\n      responses:\n        '201':\n          $ref: '#/components/responses/SingleCommentResponse'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '404':\n          $ref: '#/components/responses/NotFound'\n        '422':\n          $ref: '#/components/responses/GenericError'\n      security:\n        - Token: [ ]\n      x-codegen-request-body-name: comment\n  /articles/{slug}/comments/{id}:\n    delete:\n      tags:\n        - Comments\n      summary: Delete a comment for an article\n      description: Delete a comment for an article. Auth is required\n      operationId: DeleteArticleComment\n      parameters:\n        - name: slug\n          in: path\n          description: Slug of the article that you want to delete a comment for\n          required: true\n          schema:\n            type: string\n        - name: id\n          in: path\n          description: ID of the comment you want to delete\n          required: true\n          schema:\n            type: integer\n      responses:\n        '204':\n          $ref: '#/components/responses/EmptyOkResponse'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '403':\n          $ref: '#/components/responses/Forbidden'\n        '404':\n          $ref: '#/components/responses/NotFound'\n        '422':\n          $ref: '#/components/responses/GenericError'\n      security:\n        - Token: [ ]\n  /articles/{slug}/favorite:\n    post:\n      tags:\n        - Favorites\n      summary: Favorite an article\n      description: Favorite an article. Auth is required\n      operationId: CreateArticleFavorite\n      parameters:\n        - name: slug\n          in: path\n          description: Slug of the article that you want to favorite\n          required: true\n          schema:\n            type: string\n      responses:\n        '200':\n          $ref: '#/components/responses/SingleArticleResponse'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '404':\n          $ref: '#/components/responses/NotFound'\n        '422':\n          $ref: '#/components/responses/GenericError'\n      security:\n        - Token: [ ]\n    delete:\n      tags:\n        - Favorites\n      summary: Unfavorite an article\n      description: Unfavorite an article. Auth is required\n      operationId: DeleteArticleFavorite\n      parameters:\n        - name: slug\n          in: path\n          description: Slug of the article that you want to unfavorite\n          required: true\n          schema:\n            type: string\n      responses:\n        '200':\n          $ref: '#/components/responses/SingleArticleResponse'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '404':\n          $ref: '#/components/responses/NotFound'\n        '422':\n          $ref: '#/components/responses/GenericError'\n      security:\n        - Token: [ ]\n  /tags:\n    get:\n      tags:\n        - Tags\n      summary: Get tags\n      description: Get tags. Auth not required\n      operationId: GetTags\n      responses:\n        '200':\n          $ref: '#/components/responses/TagsResponse'\n        '422':\n          $ref: '#/components/responses/GenericError'\ncomponents:\n  schemas:\n    LoginUser:\n      required:\n        - email\n        - password\n      type: object\n      properties:\n        email:\n          type: string\n        password:\n          type: string\n          format: password\n    NewUser:\n      required:\n        - email\n        - password\n        - username\n      type: object\n      properties:\n        username:\n          type: string\n        email:\n          type: string\n        password:\n          type: string\n          format: password\n    User:\n      required:\n        - bio\n        - email\n        - image\n        - token\n        - username\n      type: object\n      properties:\n        email:\n          type: string\n        token:\n          type: string\n        username:\n          type: string\n        bio:\n          type:\n            - string\n            - 'null'\n        image:\n          type:\n            - string\n            - 'null'\n    UpdateUser:\n      type: object\n      properties:\n        email:\n          type: string\n        password:\n          type: string\n        username:\n          type: string\n        bio:\n          type:\n            - string\n            - 'null'\n        image:\n          type:\n            - string\n            - 'null'\n    Profile:\n      required:\n        - bio\n        - following\n        - image\n        - username\n      type: object\n      properties:\n        username:\n          type: string\n        bio:\n          type:\n            - string\n            - 'null'\n        image:\n          type:\n            - string\n            - 'null'\n        following:\n          type: boolean\n    Article:\n      required:\n        - author\n        - body\n        - createdAt\n        - description\n        - favorited\n        - favoritesCount\n        - slug\n        - tagList\n        - title\n        - updatedAt\n      type: object\n      properties:\n        slug:\n          type: string\n        title:\n          type: string\n        description:\n          type: string\n        body:\n          type: string\n        tagList:\n          type: array\n          items:\n            type: string\n        createdAt:\n          type: string\n          format: date-time\n        updatedAt:\n          type: string\n          format: date-time\n        favorited:\n          type: boolean\n        favoritesCount:\n          type: integer\n        author:\n          $ref: '#/components/schemas/Profile'\n    NewArticle:\n      required:\n        - body\n        - description\n        - title\n      type: object\n      properties:\n        title:\n          type: string\n        description:\n          type: string\n        body:\n          type: string\n        tagList:\n          type: array\n          items:\n            type: string\n    UpdateArticle:\n      type: object\n      properties:\n        title:\n          type: string\n        description:\n          type: string\n        body:\n          type: string\n        tagList:\n          type: array\n          items:\n            type: string\n    Comment:\n      required:\n        - author\n        - body\n        - createdAt\n        - id\n        - updatedAt\n      type: object\n      properties:\n        id:\n          type: integer\n        createdAt:\n          type: string\n          format: date-time\n        updatedAt:\n          type: string\n          format: date-time\n        body:\n          type: string\n        author:\n          $ref: '#/components/schemas/Profile'\n    NewComment:\n      required:\n        - body\n      type: object\n      properties:\n        body:\n          type: string\n    GenericErrorModel:\n      required:\n        - errors\n      type: object\n      properties:\n        errors:\n          type: object\n          additionalProperties:\n            type: array\n            items:\n              type: string\n  responses:\n    TagsResponse:\n      description: Tags\n      content:\n        application/json:\n          schema:\n            required:\n              - tags\n            type: object\n            properties:\n              tags:\n                type: array\n                items:\n                  type: string\n    SingleCommentResponse:\n      description: Single comment\n      content:\n        application/json:\n          schema:\n            required:\n              - comment\n            type: object\n            properties:\n              comment:\n                $ref: '#/components/schemas/Comment'\n    MultipleCommentsResponse:\n      description: Multiple comments\n      content:\n        application/json:\n          schema:\n            required:\n              - comments\n            type: object\n            properties:\n              comments:\n                type: array\n                items:\n                  $ref: '#/components/schemas/Comment'\n    SingleArticleResponse:\n      description: Single article\n      content:\n        application/json:\n          schema:\n            required:\n              - article\n            type: object\n            properties:\n              article:\n                $ref: '#/components/schemas/Article'\n    MultipleArticlesResponse:\n      description: Multiple articles\n      content:\n        application/json:\n          schema:\n            required:\n              - articles\n              - articlesCount\n            type: object\n            properties:\n              articles:\n                type: array\n                items:\n                  required:\n                    - author\n                    - createdAt\n                    - description\n                    - favorited\n                    - favoritesCount\n                    - slug\n                    - tagList\n                    - title\n                    - updatedAt\n                  type: object\n                  properties:\n                    slug:\n                      type: string\n                    title:\n                      type: string\n                    description:\n                      type: string\n                    tagList:\n                      type: array\n                      items:\n                        type: string\n                    createdAt:\n                      type: string\n                      format: date-time\n                    updatedAt:\n                      type: string\n                      format: date-time\n                    favorited:\n                      type: boolean\n                    favoritesCount:\n                      type: integer\n                    author:\n                      $ref: '#/components/schemas/Profile'\n              articlesCount:\n                type: integer\n    ProfileResponse:\n      description: Profile\n      content:\n        application/json:\n          schema:\n            required:\n              - profile\n            type: object\n            properties:\n              profile:\n                $ref: '#/components/schemas/Profile'\n    UserResponse:\n      description: User\n      content:\n        application/json:\n          schema:\n            required:\n              - user\n            type: object\n            properties:\n              user:\n                $ref: '#/components/schemas/User'\n    EmptyOkResponse:\n      description: No content\n      content: { }\n    Unauthorized:\n      description: Unauthorized\n      content:\n        application/json:\n          schema:\n            $ref: '#/components/schemas/GenericErrorModel'\n          example:\n            errors:\n              token:\n                - \"is missing\"\n    ConflictError:\n      description: Conflict - resource already exists\n      content:\n        application/json:\n          schema:\n            $ref: '#/components/schemas/GenericErrorModel'\n          example:\n            errors:\n              username:\n                - \"has already been taken\"\n    Forbidden:\n      description: Forbidden. The error key identifies the resource type (article, comment, etc.)\n      content:\n        application/json:\n          schema:\n            $ref: '#/components/schemas/GenericErrorModel'\n          example:\n            errors:\n              resource:\n                - \"forbidden\"\n    NotFound:\n      description: Not Found. The error key identifies the resource type (article, profile, comment, etc.)\n      content:\n        application/json:\n          schema:\n            $ref: '#/components/schemas/GenericErrorModel'\n          example:\n            errors:\n              resource:\n                - \"not found\"\n    GenericError:\n      description: Unexpected error\n      content:\n        application/json:\n          schema:\n            $ref: '#/components/schemas/GenericErrorModel'\n          example:\n            errors:\n              title:\n                - \"can't be blank\"\n  requestBodies:\n    LoginUserRequest:\n      required: true\n      description: Credentials to use\n      content:\n        application/json:\n          schema:\n            required:\n              - user\n            type: object\n            properties:\n              user:\n                $ref: '#/components/schemas/LoginUser'\n    NewUserRequest:\n      required: true\n      description: Details of the new user to register\n      content:\n        application/json:\n          schema:\n            required:\n              - user\n            type: object\n            properties:\n              user:\n                $ref: '#/components/schemas/NewUser'\n    UpdateUserRequest:\n      required: true\n      description: User details to update. At least **one** field is required.\n      content:\n        application/json:\n          schema:\n            required:\n              - user\n            type: object\n            properties:\n              user:\n                $ref: '#/components/schemas/UpdateUser'\n    NewArticleRequest:\n      required: true\n      description: Article to create\n      content:\n        application/json:\n          schema:\n            required:\n              - article\n            type: object\n            properties:\n              article:\n                $ref: '#/components/schemas/NewArticle'\n    UpdateArticleRequest:\n      required: true\n      description: Article to update\n      content:\n        application/json:\n          schema:\n            required:\n              - article\n            type: object\n            properties:\n              article:\n                $ref: '#/components/schemas/UpdateArticle'\n    NewCommentRequest:\n      required: true\n      description: Comment you want to create\n      content:\n        application/json:\n          schema:\n            required:\n              - comment\n            type: object\n            properties:\n              comment:\n                $ref: '#/components/schemas/NewComment'\n  parameters:\n    offsetParam:\n      in: query\n      name: offset\n      required: false\n      schema:\n        type: integer\n        minimum: 0\n      description: The number of items to skip before starting to collect the result set.\n    limitParam:\n      in: query\n      name: limit\n      required: false\n      schema:\n        type: integer\n        minimum: 1\n        default: 20\n      description: The numbers of items to return.\n  securitySchemes:\n    Token:\n      type: apiKey\n      description: \"For accessing the protected API resources, you must have received\\\n        \\ a a valid JWT token after registering or logging in. This JWT token must\\\n        \\ then be used for all protected resources by passing it in via the 'Authorization'\\\n        \\ header.\\n\\nA JWT token is generated by the API by either registering via\\\n        \\ /users or logging in via /users/login.\\n\\nThe following format must be in\\\n        \\ the 'Authorization' header :\\n\\n    Token xxxxxx.yyyyyyy.zzzzzz\\n    \\n\"\n      name: Authorization\n      in: header\n"
  },
  {
    "path": "specs/api/run-api-tests-bruno.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nDIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nHOST=\"${HOST:-http://localhost:8000}\"\n\necho \"Running Bruno tests against $HOST\"\n\nFOLDERS=(\"$@\")\nif [ ${#FOLDERS[@]} -eq 0 ]; then\n  for entry in \"$DIR\"/bruno/*/; do\n    name=\"$(basename \"$entry\")\"\n    [ \"$name\" = \"environments\" ] && continue\n    FOLDERS+=(\"$entry\")\n  done\nfi\n\nfor folder in \"${FOLDERS[@]}\"; do\n  echo \"\"\n  echo \"--- bru run $folder ---\"\n  bru run \"$folder\" --env local --env-var \"host=$HOST\"\ndone\n"
  },
  {
    "path": "specs/api/run-api-tests-hurl.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nDIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nHOST=\"${HOST:-http://localhost:8000}\"\nUID_VAL=\"${UID_VAL:-$(date +%s)$$}\"\n\necho \"Running Hurl tests against $HOST with uid=$UID_VAL\"\n\nFILES=(\"$@\")\nif [ ${#FILES[@]} -eq 0 ]; then\n  FILES=(\"$DIR\"/hurl/*.hurl)\nfi\n\nhurl --test \\\n  --jobs 1 \\\n  --variable \"host=$HOST\" \\\n  --variable \"uid=$UID_VAL\" \\\n  \"${FILES[@]}\"\n"
  },
  {
    "path": "specs/e2e/SELECTORS.md",
    "content": "# RealWorld E2E Test Selectors Contract\n\nThis document lists every CSS class, HTML attribute, text label, route, and\ninterface that the shared e2e tests depend on. Any RealWorld implementation\nthat wants to use these tests **must** provide all of the selectors below.\n\n---\n\n## Form Inputs (`name` attributes)\n\nInputs are located via the standard HTML `name` attribute.\n\n| Selector                                     | Page                      |\n| -------------------------------------------- | ------------------------- |\n| `input[name=\"username\"]`                     | Register, Settings        |\n| `input[name=\"email\"]`                        | Login, Register, Settings |\n| `input[name=\"password\"]`                     | Login, Register, Settings |\n| `input[name=\"title\"]`                        | Editor                    |\n| `input[name=\"description\"]`                  | Editor                    |\n| `textarea[name=\"body\"]`                      | Editor                    |\n| `input[name=\"image\"]`                        | Settings                  |\n| `textarea[name=\"bio\"]`                       | Settings                  |\n| `input[placeholder=\"Enter tags\"]`            | Editor                    |\n| `textarea[placeholder=\"Write a comment...\"]` | Article detail            |\n\n---\n\n## CSS Classes\n\n### Layout & Navigation\n\n| Class           | Element | Purpose               |\n| --------------- | ------- | --------------------- |\n| `.navbar`       | `nav`   | Main navigation bar   |\n| `.navbar-brand` | `a`     | Logo / site name link |\n| `.nav-link`     | `a`     | Navigation tab links  |\n| `.banner`       | `div`   | Home page hero banner |\n| `.container`    | `div`   | Page width container  |\n\n### Feed & Articles\n\n| Class                 | Element | Purpose                             |\n| --------------------- | ------- | ----------------------------------- |\n| `.feed-toggle`        | `div`   | Global Feed / Your Feed tab bar     |\n| `.article-preview`    | `div`   | Article card in a feed list         |\n| `.article-meta`       | `div`   | Author avatar + name + date         |\n| `.article-content`    | `div`   | Rendered article body               |\n| `.article-page`       | `div`   | Article detail page wrapper         |\n| `.preview-link`       | `a`     | Clickable link wrapping the preview |\n| `.author`             | `a`     | Author name in article meta         |\n| `.empty-feed-message` | `div`   | \"No articles here\" placeholder      |\n\n### Tags\n\n| Class          | Element | Purpose                 |\n| -------------- | ------- | ----------------------- |\n| `.sidebar`     | `div`   | Home page sidebar       |\n| `.tag-list`    | `div`   | Container for tag pills |\n| `.tag-default` | `span`  | Base tag class          |\n| `.tag-pill`    | `span`  | Pill-shaped tag         |\n\n### Comments\n\n| Class                 | Element | Purpose                                   |\n| --------------------- | ------- | ----------------------------------------- |\n| `.card`               | `div`   | Comment card (also wraps `.comment-form`) |\n| `.card-block`         | `div`   | Comment text body                         |\n| `.comment-form`       | `div`   | Comment input form card                   |\n| `.comment-author-img` | `img`   | Commenter's avatar                        |\n| `.mod-options`        | `span`  | Delete button container                   |\n| `.ion-trash-a`        | `i`     | Delete icon (inside `.mod-options`)       |\n\nTests select posted comments with `.card:not(.comment-form) .card-block`.\n\n### Profile\n\n| Class           | Element | Purpose                       |\n| --------------- | ------- | ----------------------------- |\n| `.profile-page` | `div`   | Profile page wrapper          |\n| `.user-info`    | `div`   | Username, bio, avatar section |\n| `.user-img`     | `img`   | Large avatar on profile page  |\n| `.user-pic`     | `img`   | Small avatar in the navbar    |\n\n### Pagination\n\n| Class         | Element   | Purpose                        |\n| ------------- | --------- | ------------------------------ |\n| `.pagination` | `nav/div` | Pagination container           |\n| `.page-item`  | `li/div`  | Individual page button wrapper |\n\n### Buttons\n\n| Class                  | Element  | Purpose                        |\n| ---------------------- | -------- | ------------------------------ |\n| `.btn-outline-primary` | `button` | Favorite (not yet favorited)   |\n| `.btn-primary`         | `button` | Unfavorite (already favorited) |\n| `.btn-outline-danger`  | `button` | Destructive action (logout)    |\n\n### Errors\n\n| Class             | Element | Purpose                     |\n| ----------------- | ------- | --------------------------- |\n| `.error-messages` | `ul`    | Validation / API error list |\n\n---\n\n## Required Text Content\n\nButtons and links are located by visible text via `:has-text()`.\n\n### Buttons\n\n| Text                      | Element  | Context                  |\n| ------------------------- | -------- | ------------------------ |\n| `Post Comment`            | `button` | Article detail           |\n| `Delete Article`          | `button` | Article detail           |\n| `Publish Article`         | `button` | Editor                   |\n| `Update Settings`         | `button` | Settings                 |\n| `Or click here to logout` | `button` | Settings                 |\n| `Follow` / `Unfollow`     | `button` | Profile, article meta    |\n| `Favorite` / `Unfavorite` | `button` | Article detail           |\n| `Favorite Article`        | `button` | Article detail (variant) |\n\n### Headings & Links\n\n| Text                    | Element      | Context               |\n| ----------------------- | ------------ | --------------------- |\n| `Sign in`               | `h1`         | Login page heading    |\n| `Sign up`               | `h1`         | Register page heading |\n| `Global Feed`           | `a.nav-link` | Home feed toggle      |\n| `Your Feed`             | `a.nav-link` | Home feed toggle      |\n| `Favorited`             | `a`          | Profile tab           |\n| `Edit Article`          | `a`          | Article detail        |\n| `Edit Profile Settings` | `a`          | Profile page          |\n| `Home`                  | `a.nav-link` | Navbar                |\n\n---\n\n## Routes\n\n### Pages\n\n| Route                          | Page                      |\n| ------------------------------ | ------------------------- |\n| `/`                            | Home / Global Feed        |\n| `/?feed=following`             | Your Feed                 |\n| `/?page=N`                     | Paginated feed            |\n| `/tag/:tag`                    | Filtered by tag           |\n| `/tag/:tag?page=N`             | Paginated tag view        |\n| `/login`                       | Login                     |\n| `/register`                    | Register                  |\n| `/editor`                      | New article               |\n| `/editor/:slug`                | Edit article              |\n| `/settings`                    | User settings             |\n| `/profile/:username`           | User profile              |\n| `/profile/:username/favorites` | User's favorited articles |\n| `/article/:slug`               | Article detail            |\n\n### API Endpoints (used in route interception)\n\n| Endpoint                           | Methods          |\n| ---------------------------------- | ---------------- |\n| `/api/articles*`                   | GET              |\n| `/api/articles/:slug`              | GET, PUT, DELETE |\n| `/api/articles/:slug/comments`     | GET, POST        |\n| `/api/articles/:slug/comments/:id` | DELETE           |\n| `/api/articles/:slug/favorite`     | POST, DELETE     |\n| `/api/users`                       | POST (register)  |\n| `/api/users/login`                 | POST             |\n| `/api/user`                        | GET, PUT         |\n| `/api/profiles/:username`          | GET              |\n| `/api/profiles/:username/follow`   | POST, DELETE     |\n| `/api/tags`                        | GET              |\n\n---\n\n## Debug Interface (`window.__conduit_debug__`)\n\nImplementations **must** expose this on `window.__conduit_debug__`:\n\n```typescript\ninterface ConduitDebug {\n  getToken(): string | null;\n  getAuthState(): 'authenticated' | 'unauthenticated' | 'unavailable' | 'loading';\n  getCurrentUser(): { username: string; email: string; bio: string | null; image: string | null; token: string } | null;\n}\n```\n\nSee `helpers/debug.ts` for the full contract and implementation guide.\n\n---\n\n## LocalStorage\n\n| Key        | Value      | Purpose              |\n| ---------- | ---------- | -------------------- |\n| `jwtToken` | JWT string | Authentication token |\n\n---\n\n## Default Avatar\n\nWhen a user's `image` is `null` or empty, the `src` attribute of avatar\nimages (`.user-img`, `.user-pic`, `.comment-author-img`, `.article-meta img`)\nmust contain `default-avatar.svg`.\n\n---\n\n## Button State Conventions\n\n- **Favorite**: Uses `.btn-outline-primary` when not favorited, switches to `.btn-primary` when favorited.\n- **Follow**: Button text toggles between `Follow {username}` and `Unfollow {username}`.\n- **Pagination active**: The current page's `.page-item` has the CSS class `active`.\n"
  },
  {
    "path": "specs/e2e/articles.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { register, generateUniqueUser } from './helpers/auth';\nimport {\n  createArticle,\n  editArticle,\n  deleteArticle,\n  favoriteArticle,\n  unfavoriteArticle,\n  generateUniqueArticle,\n} from './helpers/articles';\nimport { API_MODE } from './helpers/config';\n\ntest.describe('Articles', () => {\n  test.beforeEach(async ({ page }) => {\n    // Register and login before each test\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n  });\n\n  test.afterEach(async ({ context }) => {\n    // Close the browser context to ensure complete isolation between tests.\n    // This releases browser instances, network connections, and other resources.\n    await context.close();\n    // Wait 500ms to allow async cleanup operations to complete.\n    // Without this delay, running 6+ tests in sequence causes flaky failures\n    // due to resource exhaustion (network connections, file descriptors, etc).\n    // This timing issue manifests as timeouts when loading article pages.\n    // This will be investigated and fixed later.\n    await new Promise(resolve => setTimeout(resolve, 500));\n  });\n\n  test('should create a new article', async ({ page }) => {\n    const article = generateUniqueArticle();\n\n    await createArticle(page, article);\n\n    // Should be on article page\n    await expect(page).toHaveURL(/\\/article\\/.+/);\n\n    // Should show article content\n    await expect(page.locator('h1')).toHaveText(article.title);\n    await expect(page.locator('.article-content p')).toContainText(article.body);\n\n    // Should show tags\n    for (const tag of article.tags || []) {\n      await expect(page.locator(`.tag-list .tag-default:has-text(\"${tag}\")`)).toBeVisible();\n    }\n  });\n\n  test('should edit an existing article', async ({ page }) => {\n    const article = generateUniqueArticle();\n\n    await createArticle(page, article);\n\n    // Get the article slug from URL\n    const url = page.url();\n    const slug = url.split('/article/')[1];\n\n    // Edit the article\n    const updates = {\n      title: `Updated ${article.title}`,\n      description: `Updated ${article.description}`,\n    };\n\n    await editArticle(page, slug, updates);\n\n    // Should show updated content\n    await expect(page.locator('h1')).toHaveText(updates.title);\n  });\n\n  test('should delete an article', async ({ page }) => {\n    const article = generateUniqueArticle();\n\n    await createArticle(page, article);\n\n    // Delete the article\n    await deleteArticle(page);\n\n    // Should be redirected to home\n    await expect(page).toHaveURL('/');\n\n    // Article should not appear on home page\n    await expect(page.locator(`h1:has-text(\"${article.title}\")`)).not.toBeVisible();\n  });\n\n  /**\n   * Verifies the frontend handles HTTP 200 for article deletion.\n   *\n   * The RealWorld spec uses 204 No Content for DELETE operations, which is\n   * semantically correct (success with no response body). However, HTTP clients\n   * should accept ANY 2XX status as success per RFC 9110.\n   *\n   * This test mocks a 200 response to verify the frontend doesn't break when\n   * an implementation returns 200 instead of 204. This is good engineering\n   * practice: clients should handle status code classes, not specific codes.\n   */\n  test('should delete an article when server returns 200 instead of 204', async ({ page }) => {\n    test.skip(!API_MODE, 'API-only: tests client-side HTTP status code handling via page.route()');\n    const article = generateUniqueArticle();\n\n    await createArticle(page, article);\n\n    // Intercept DELETE requests and respond with 200 instead of 204\n    await page.route('**/api/articles/*', async route => {\n      if (route.request().method() === 'DELETE') {\n        await route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({}),\n        });\n      } else {\n        await route.continue();\n      }\n    });\n\n    // Delete the article\n    await deleteArticle(page);\n\n    // Should be redirected to home (frontend should handle 200 the same as 204)\n    await expect(page).toHaveURL('/');\n  });\n\n  test('should favorite an article', async ({ page }) => {\n    // Use an existing article from the demo backend (can't favorite own articles)\n    // Go to global feed to see all articles\n    await page.goto('/', { waitUntil: 'load' });\n\n    // Click on the first article to go to its detail page\n    await page.click('.article-preview h1');\n    await page.waitForLoadState('load');\n\n    // Favorite the article using the helper (which expects to be on article detail page)\n    await favoriteArticle(page);\n\n    // Should see unfavorite button (use .first() since there are 2 buttons on the page)\n    await expect(page.locator('button:has-text(\"Unfavorite\")').first()).toBeVisible();\n  });\n\n  test('should unfavorite an article', async ({ page }) => {\n    // Go to global feed to find an article from demo backend (not own article)\n    await page.goto('/', { waitUntil: 'load' });\n\n    // Wait for articles to load\n    await page.waitForSelector('.article-preview', { timeout: 10000 });\n\n    // Get the username of the currently logged in user from the navbar\n    const currentUsername = await page.locator('nav a[href^=\"/profile/\"]').first().textContent();\n\n    // Find an article that's NOT from the current user\n    const articles = await page.locator('.article-preview').all();\n    let articleToFavorite = null;\n\n    for (const article of articles) {\n      const authorName = await article.locator('.author').textContent();\n      if (authorName?.trim() !== currentUsername?.trim()) {\n        articleToFavorite = article;\n        break;\n      }\n    }\n\n    if (!articleToFavorite) {\n      throw new Error('No articles from other users found');\n    }\n\n    // Click on the article\n    await articleToFavorite.locator('h1').click();\n    await page.waitForURL(/\\/article\\/.+/);\n\n    // Wait for article page to load - should see Favorite button (not Delete button)\n    await page.waitForSelector('button:has-text(\"Favorite\")', { timeout: 10000 });\n\n    // Favorite it first\n    await favoriteArticle(page);\n\n    // Then unfavorite it\n    await unfavoriteArticle(page);\n\n    // Should see favorite button again (use .first() since there are 2 buttons on the page)\n    await expect(page.locator('button:has-text(\"Favorite\")').first()).toBeVisible();\n  });\n\n  test('should view article from home feed', async ({ page }) => {\n    const article = generateUniqueArticle();\n\n    await createArticle(page, article);\n\n    // Go to global feed to see the article we just created\n    await page.goto('/', { waitUntil: 'load' });\n\n    // Wait for articles to load\n    await page.waitForSelector('.article-preview', { timeout: 10000 });\n\n    // Wait for our specific article to appear\n    await page.waitForSelector(`h1:has-text(\"${article.title}\")`, { timeout: 10000 });\n\n    // Click on the article link in the feed (h1 is inside a link)\n    await Promise.all([\n      page.waitForURL(/\\/article\\/.+/),\n      page.locator(`h1:has-text(\"${article.title}\")`).first().click(),\n    ]);\n\n    // Should be on article page\n    await expect(page).toHaveURL(/\\/article\\/.+/);\n    await expect(page.locator('h1')).toHaveText(article.title);\n  });\n\n  test('should display article preview correctly', async ({ page }) => {\n    const article = generateUniqueArticle();\n\n    await createArticle(page, article);\n\n    // Go to global feed to see the article we just created\n    await page.goto('/');\n\n    // Article preview should show correct information\n    const preview = page.locator('.article-preview').first();\n    await expect(preview.locator('h1')).toHaveText(article.title);\n    await expect(preview.locator('p')).toContainText(article.description);\n\n    // Should show author info\n    await expect(preview.locator('.author')).toBeVisible();\n\n    // Should show tags\n    for (const tag of article.tags || []) {\n      await expect(preview.locator(`.tag-list .tag-default:has-text(\"${tag}\")`)).toBeVisible();\n    }\n  });\n\n  test('should remove all tags when editing an article', async ({ page }) => {\n    const article = generateUniqueArticle();\n\n    await createArticle(page, article);\n\n    // Should show tags on the article page\n    for (const tag of article.tags || []) {\n      await expect(page.locator(`.tag-list .tag-default:has-text(\"${tag}\")`)).toBeVisible();\n    }\n\n    // Get the article slug from URL\n    const url = page.url();\n    const slug = url.split('/article/')[1];\n\n    // Go to the editor\n    await page.goto(`/editor/${slug}`, { waitUntil: 'load' });\n\n    // Wait for the form to be populated\n    const titleInput = page.locator('input[name=\"title\"]');\n    await expect(titleInput).not.toHaveValue('', { timeout: 10000 });\n\n    // Remove all tag pills by clicking their delete icons\n    while (await page.locator('.tag-list .tag-pill i, .tag-list .tag-default i').count() > 0) {\n      await page.locator('.tag-list .tag-pill i, .tag-list .tag-default i').first().click();\n      await page.waitForTimeout(100);\n    }\n\n    // Intercept the PUT request to verify tagList is sent as [] (SPA-only: fullstack doesn't use fetch)\n    let capturedTagList: unknown = undefined;\n    if (API_MODE) {\n      await page.route('**/api/articles/*', async route => {\n        if (route.request().method() === 'PUT') {\n          const body = route.request().postDataJSON();\n          capturedTagList = body?.article?.tagList;\n          await route.continue();\n        } else {\n          await route.continue();\n        }\n      });\n    }\n\n    // Publish\n    await Promise.all([page.waitForURL(/\\/article\\/.+/), page.click('button:has-text(\"Publish Article\")')]);\n\n    // Verify the frontend sent tagList: [] (not undefined/omitted) — SPA only\n    if (API_MODE) {\n      expect(capturedTagList).toEqual([]);\n    }\n\n    // Verify no tags on the article page\n    await expect(page.locator('.tag-list .tag-default')).toHaveCount(0);\n  });\n\n  test('should only allow author to edit/delete article', async ({ page, browser }) => {\n    const article = generateUniqueArticle();\n\n    // Create article as first user\n    await createArticle(page, article);\n\n    // Get article URL\n    const articleUrl = page.url();\n\n    // Create a second user in new context (not sharing cookies with first user)\n    const context2 = await browser.newContext();\n    const page2 = await context2.newPage();\n    const user2 = generateUniqueUser();\n    await register(page2, user2.username, user2.email, user2.password);\n\n    // Visit the article as second user\n    await page2.goto(articleUrl);\n\n    // Should not see Edit/Delete buttons\n    await expect(page2.locator('a:has-text(\"Edit Article\")')).not.toBeVisible();\n    await expect(page2.locator('button:has-text(\"Delete Article\")')).not.toBeVisible();\n\n    await context2.close();\n  });\n});\n"
  },
  {
    "path": "specs/e2e/auth.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { register, login, logout, generateUniqueUser } from './helpers/auth';\nimport { getToken, getAuthState } from './helpers/debug';\nimport { API_MODE } from './helpers/config';\n\ntest.describe('Authentication', () => {\n  test('should register a new user', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n    // Should be redirected to home page\n    await expect(page).toHaveURL('/');\n    // Should see username in header\n    await expect(page.locator(`a[href=\"/profile/${user.username}\"]`)).toBeVisible();\n    // Should be able to access editor\n    await page.click('a[href=\"/editor\"]');\n    await expect(page).toHaveURL('/editor');\n  });\n\n  test('should login with existing user', async ({ page }) => {\n    const user = generateUniqueUser();\n    // First register a user\n    await register(page, user.username, user.email, user.password);\n    // Logout\n    await logout(page);\n    // Should see Sign in link\n    await expect(page.locator('a[href=\"/login\"]')).toBeVisible();\n    // Login again\n    await login(page, user.email, user.password);\n    // Should be logged in\n    await expect(page.locator(`a[href=\"/profile/${user.username}\"]`)).toBeVisible();\n  });\n\n  test('should show error for invalid login', async ({ page }) => {\n    await page.goto('/login');\n    await page.fill('input[name=\"email\"]', 'nonexistent@example.com');\n    await page.fill('input[name=\"password\"]', 'wrongpassword');\n    await page.click('button[type=\"submit\"]');\n    // Should show error message\n    await expect(page.locator('.error-messages')).toBeVisible();\n  });\n\n  test('should fail login with wrong password', async ({ page }) => {\n    const user = generateUniqueUser();\n    // First register a user with correct credentials\n    await register(page, user.username, user.email, user.password);\n    // Logout\n    await logout(page);\n    // Try to login with correct email but wrong password\n    await page.goto('/login');\n    await page.fill('input[name=\"email\"]', user.email);\n    await page.fill('input[name=\"password\"]', 'wrongpassword123');\n    await page.click('button[type=\"submit\"]');\n    // Should show error message\n    await expect(page.locator('.error-messages')).toBeVisible();\n    // Should still be on login page (not redirected)\n    await expect(page).toHaveURL('/login');\n  });\n\n  test('should logout successfully', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n    // User should be logged in\n    await expect(page.locator(`a[href=\"/profile/${user.username}\"]`)).toBeVisible();\n    // Logout\n    await logout(page);\n    // Should see Sign in link (user is logged out)\n    await expect(page.locator('a[href=\"/login\"]')).toBeVisible();\n    // Should not see profile link\n    await expect(page.locator(`a[href=\"/profile/${user.username}\"]`)).not.toBeVisible();\n  });\n\n  test('should prevent accessing editor when not logged in', async ({ page }) => {\n    await page.goto('/editor');\n    // Should be redirected to login or home\n    await expect(page).not.toHaveURL('/editor');\n  });\n\n  test('should maintain session after page reload', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n    // Reload the page\n    await page.reload();\n    // Should still be logged in\n    await expect(page.locator(`a[href=\"/profile/${user.username}\"]`)).toBeVisible();\n  });\n\n  test('should handle invalid token on page reload gracefully', async ({ page }) => {\n    test.skip(!API_MODE, 'API-only: tests localStorage token handling');\n    // Set an invalid token in localStorage before navigating\n    await page.goto('/');\n    await page.evaluate(() => {\n      localStorage.setItem('jwtToken', 'invalid-token-that-will-cause-401');\n    });\n    // Reload the page - this should NOT cause a blank screen\n    await page.reload();\n    // The app should still load and show the unauthenticated UI\n    await expect(page.locator('a[href=\"/login\"]')).toBeVisible();\n    await expect(page.locator('a[href=\"/register\"]')).toBeVisible();\n    // The invalid token should be cleared (use debug interface)\n    const token = await getToken(page);\n    expect(token).toBeNull();\n    const authState = await getAuthState(page);\n    expect(authState).toBe('unauthenticated');\n  });\n});\n"
  },
  {
    "path": "specs/e2e/comments.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { register, generateUniqueUser } from './helpers/auth';\nimport { createArticle, generateUniqueArticle } from './helpers/articles';\nimport { addComment, deleteComment, getCommentCount } from './helpers/comments';\nimport { API_MODE } from './helpers/config';\n\ntest.describe('Comments', () => {\n  // Force each test to use a fresh browser context\n  test.use({ storageState: undefined });\n\n  test.beforeEach(async ({ page }) => {\n    // Register and login, then create an article\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n    const article = generateUniqueArticle();\n    await createArticle(page, article);\n  });\n\n  test.afterEach(async ({ context }) => {\n    // Close the browser context to ensure complete isolation between tests.\n    // This releases browser instances, network connections, and other resources.\n    await context.close();\n    // Wait 500ms to allow async cleanup operations to complete.\n    // Without this delay, running 6+ tests in sequence causes flaky failures\n    // due to resource exhaustion (network connections, file descriptors, etc).\n    // This timing issue manifests as timeouts when loading article pages.\n    // This will be investigated and fixed later.\n    await new Promise(resolve => setTimeout(resolve, 500));\n  });\n\n  test('should add a comment to an article', async ({ page }) => {\n    const commentText = 'This is a test comment from Playwright!';\n    await addComment(page, commentText);\n    // Comment should be visible\n    await expect(page.locator(`.card:not(.comment-form) .card-block:has-text(\"${commentText}\")`)).toBeVisible();\n  });\n\n  test('should delete own comment', async ({ page }) => {\n    const commentText = 'Comment to be deleted';\n    await addComment(page, commentText);\n    // Comment should be visible\n    await expect(page.locator(`.card:not(.comment-form) .card-block:has-text(\"${commentText}\")`)).toBeVisible();\n    // Delete the comment\n    await deleteComment(page, commentText);\n    // Comment should no longer be visible\n    await expect(page.locator(`.card:not(.comment-form) .card-block:has-text(\"${commentText}\")`)).not.toBeVisible();\n  });\n\n  /**\n   * Verifies the frontend handles HTTP 200 for comment deletion.\n   *\n   * The RealWorld spec uses 204 No Content for DELETE operations, which is\n   * semantically correct (success with no response body). However, HTTP clients\n   * should accept ANY 2XX status as success per RFC 9110.\n   *\n   * This test mocks a 200 response to verify the frontend doesn't break when\n   * an implementation returns 200 instead of 204. This is good engineering\n   * practice: clients should handle status code classes, not specific codes.\n   */\n  test('should delete comment when server returns 200 instead of 204', async ({ page }) => {\n    test.skip(!API_MODE, 'API-only: tests client-side HTTP status code handling via page.route()');\n    const commentText = 'Comment to test 200 status';\n    await addComment(page, commentText);\n    // Comment should be visible\n    await expect(page.locator(`.card:not(.comment-form) .card-block:has-text(\"${commentText}\")`)).toBeVisible();\n\n    // Intercept DELETE requests to comments and respond with 200 instead of 204\n    await page.route('**/api/articles/*/comments/*', async route => {\n      if (route.request().method() === 'DELETE') {\n        await route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({}),\n        });\n      } else {\n        await route.continue();\n      }\n    });\n\n    // Delete the comment\n    await deleteComment(page, commentText);\n    // Comment should no longer be visible (frontend should handle 200 the same as 204)\n    await expect(page.locator(`.card:not(.comment-form) .card-block:has-text(\"${commentText}\")`)).not.toBeVisible();\n  });\n\n  test('should display multiple comments', async ({ page }) => {\n    const comment1 = 'First comment';\n    const comment2 = 'Second comment';\n    const comment3 = 'Third comment';\n    await addComment(page, comment1);\n    await addComment(page, comment2);\n    await addComment(page, comment3);\n    // All comments should be visible (exclude comment form)\n    await expect(page.locator(`.card:not(.comment-form) .card-block:has-text(\"${comment1}\")`)).toBeVisible();\n    await expect(page.locator(`.card:not(.comment-form) .card-block:has-text(\"${comment2}\")`)).toBeVisible();\n    await expect(page.locator(`.card:not(.comment-form) .card-block:has-text(\"${comment3}\")`)).toBeVisible();\n    // Should have exactly 3 comments\n    const count = await getCommentCount(page);\n    expect(count).toBe(3);\n  });\n\n  test('should require login to post comment', async ({ page, browser }) => {\n    // Create a new context without authentication (not sharing cookies with page)\n    const context2 = await browser.newContext();\n    const page2 = await context2.newPage();\n    // Visit the article and wait for navigation\n    await page2.goto(page.url(), { waitUntil: 'load' });\n    // Wait for Angular to complete auth check - either comment form OR sign in link appears\n    await page2.waitForSelector('textarea[placeholder=\"Write a comment...\"], a[href=\"/login\"]', { timeout: 10000 });\n    // Should see sign in/sign up links instead of comment form\n    await expect(page2.locator('a[href=\"/login\"]')).toBeVisible();\n    await expect(page2.locator('textarea[placeholder=\"Write a comment...\"]')).not.toBeVisible();\n    await context2.close();\n  });\n\n  test('should only allow comment author to delete', async ({ page }) => {\n    // Use an existing demo article from the backend (e.g., johndoe's article)\n    // This avoids session isolation issues\n    // Go to global feed to see all articles\n    await page.goto('/', { waitUntil: 'load' });\n    // Wait for article to be fully loaded and clickable\n    await expect(page.locator('.article-preview h1').first()).toBeVisible({ timeout: 15000 });\n    // Click on first article from demo backend (likely has existing comments)\n    await page.click('.article-preview h1');\n    await page.waitForURL(/\\/article\\/.+/, { timeout: 10000 });\n    // Check if there are any existing comments (from other users like johndoe)\n    const existingCommentsCount = await page.locator('.card:not(.comment-form)').count();\n    // If there are existing comments, they should NOT have delete buttons (not our comments)\n    if (existingCommentsCount > 0) {\n      const firstExistingComment = page.locator('.card:not(.comment-form)').first();\n      await expect(firstExistingComment.locator('span.mod-options i.ion-trash-a')).not.toBeVisible();\n    }\n    // Now add our own comment\n    const commentText = `Comment by logged in user ${Date.now()}`;\n    await addComment(page, commentText);\n    // Verify the delete button IS visible for OUR comment\n    const ownComment = page.locator('.card', { has: page.locator(`text=\"${commentText}\"`) });\n    await expect(ownComment.locator('span.mod-options i.ion-trash-a')).toBeVisible();\n  });\n\n  test('should handle long comments', async ({ page }) => {\n    const longComment = 'This is a very long comment. '.repeat(50);\n    await addComment(page, longComment);\n    // Comment should be visible and properly formatted (comment text is in a paragraph)\n    await expect(page.locator('p').filter({ hasText: longComment })).toBeVisible();\n  });\n\n  test('should preserve comments after page reload', async ({ page }) => {\n    const commentText = 'Persistent comment';\n    await addComment(page, commentText);\n    // Reload the page\n    await page.reload();\n    // Comment should still be visible\n    await expect(page.locator(`.card:not(.comment-form) .card-block:has-text(\"${commentText}\")`)).toBeVisible();\n  });\n\n  test('should clear comment form after posting', async ({ page }) => {\n    const commentText = 'Test comment';\n    await addComment(page, commentText);\n    // Comment textarea should be empty\n    await expect(page.locator('textarea[placeholder=\"Write a comment...\"]')).toHaveValue('');\n  });\n});\n"
  },
  {
    "path": "specs/e2e/error-handling.spec.ts",
    "content": "import { test, expect, Page, Route } from '@playwright/test';\nimport { API_MODE } from './helpers/config';\n\ntest.beforeEach(({ }, testInfo) => {\n  testInfo.skip(!API_MODE, 'API-only: all tests use page.route() API mocking');\n});\n\nconst API_BASE = 'https://api.realworld.show/api';\n\n/**\n * Helper to mock an API endpoint with a specific error response\n */\nasync function mockApiError(page: Page, endpoint: string, status: number, errorBody: object = {}, method?: string) {\n  await page.route(`${API_BASE}${endpoint}`, (route: Route) => {\n    if (method && route.request().method() !== method) {\n      return route.continue();\n    }\n    route.fulfill({\n      status,\n      contentType: 'application/json',\n      body: JSON.stringify(errorBody),\n    });\n  });\n}\n\n/**\n * Helper to set a fake JWT token to simulate authenticated state\n */\nasync function setFakeAuthToken(page: Page) {\n  await page.evaluate(() => {\n    localStorage.setItem('jwtToken', 'fake-token-for-testing');\n  });\n}\n\ntest.describe('Error Handling - 400 Bad Request', () => {\n  test('should handle 400 on login with validation errors', async ({ page }) => {\n    await mockApiError(page, '/users/login', 400, {\n      errors: { 'email or password': ['is invalid'] },\n    });\n    await page.goto('/login');\n    await page.fill('input[name=\"email\"]', 'test@test.com');\n    await page.fill('input[name=\"password\"]', 'password');\n    await page.click('button[type=\"submit\"]');\n    // Should show error messages, not crash\n    await expect(page.locator('.error-messages')).toBeVisible();\n    await expect(page).toHaveURL('/login');\n    await expect(page.locator('input[name=\"email\"]')).toBeVisible();\n  });\n\n  test('should handle 400 on registration with validation errors', async ({ page }) => {\n    await mockApiError(page, '/users', 400, {\n      errors: {\n        email: ['is already taken'],\n        username: ['is too short (minimum is 3 characters)'],\n      },\n    });\n    await page.goto('/register');\n    await page.fill('input[name=\"username\"]', 'ab');\n    await page.fill('input[name=\"email\"]', 'taken@test.com');\n    await page.fill('input[name=\"password\"]', 'password123');\n    await page.click('button[type=\"submit\"]');\n    // Should show error messages\n    await expect(page.locator('.error-messages')).toBeVisible();\n    await expect(page).toHaveURL('/register');\n    await expect(page.locator('input[name=\"email\"]')).toBeVisible();\n  });\n\n  test('should handle 400 on article creation', async ({ page }) => {\n    await page.goto('/');\n    await setFakeAuthToken(page);\n    // Mock successful user fetch (so we appear logged in)\n    await page.route(`${API_BASE}/user`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          user: { username: 'testuser', email: 'test@test.com', token: 'fake-token', bio: null, image: null },\n        }),\n      });\n    });\n    await mockApiError(\n      page,\n      '/articles',\n      400,\n      {\n        errors: { title: [\"can't be blank\"], body: [\"can't be blank\"] },\n      },\n      'POST',\n    );\n    await page.goto('/editor');\n    await page.fill('input[name=\"title\"]', '');\n    await page.fill('input[name=\"description\"]', 'desc');\n    await page.fill('textarea[name=\"body\"]', '');\n    await page.click('button:has-text(\"Publish\")');\n    // Should show errors, not crash\n    await expect(page.locator('.error-messages')).toBeVisible();\n    await expect(page.locator('input[name=\"title\"]')).toBeVisible();\n  });\n});\n\ntest.describe('Error Handling - 401 Unauthorized', () => {\n  // Note: 401 on page load is tested in user-fetch-errors.spec.ts\n\n  test('should handle 401 when submitting settings form', async ({ page }) => {\n    // Mock GET /user to succeed (so we can load the settings page)\n    // Mock PUT /user to return 401 (session expired mid-edit)\n    await page.route(`${API_BASE}/user`, route => {\n      if (route.request().method() === 'GET') {\n        route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({\n            user: { username: 'testuser', email: 'test@test.com', token: 'fake-token', bio: 'bio', image: null },\n          }),\n        });\n      } else if (route.request().method() === 'PUT') {\n        route.fulfill({\n          status: 401,\n          contentType: 'application/json',\n          body: JSON.stringify({ errors: { message: ['Session expired'] } }),\n        });\n      }\n    });\n    await page.goto('/');\n    await setFakeAuthToken(page);\n    await page.goto('/settings');\n    // Wait for form to load\n    await expect(page.locator('input[name=\"email\"]')).toBeVisible();\n    // Submit the form\n    await page.click('button[type=\"submit\"]');\n    // Should show error message, form should still be usable\n    await expect(page.locator('.error-messages')).toBeVisible();\n    await expect(page.locator('input[name=\"email\"]')).toBeVisible();\n  });\n\n  test('should handle 401 when posting a comment', async ({ page }) => {\n    // Mock article fetch as successful\n    await page.route(`${API_BASE}/articles/*`, route => {\n      if (route.request().method() === 'GET') {\n        route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({\n            article: {\n              slug: 'test-article',\n              title: 'Test Article',\n              description: 'Test',\n              body: 'Test body',\n              tagList: [],\n              createdAt: new Date().toISOString(),\n              updatedAt: new Date().toISOString(),\n              favorited: false,\n              favoritesCount: 0,\n              author: { username: 'author', bio: null, image: null, following: false },\n            },\n          }),\n        });\n      } else {\n        route.continue();\n      }\n    });\n    // Mock comments fetch\n    await page.route(`${API_BASE}/articles/*/comments`, route => {\n      if (route.request().method() === 'GET') {\n        route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({ comments: [] }),\n        });\n      } else if (route.request().method() === 'POST') {\n        route.fulfill({\n          status: 401,\n          contentType: 'application/json',\n          body: JSON.stringify({ errors: { message: ['You must be logged in'] } }),\n        });\n      }\n    });\n    await page.goto('/');\n    await setFakeAuthToken(page);\n    // Mock user as logged in\n    await page.route(`${API_BASE}/user`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          user: { username: 'testuser', email: 'test@test.com', token: 'fake-token', bio: null, image: null },\n        }),\n      });\n    });\n    await page.goto('/article/test-article');\n    // App should handle gracefully - not crash\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    // Article content should still be visible\n    await expect(page.locator('.article-content')).toBeVisible();\n  });\n});\n\ntest.describe('Error Handling - 403 Forbidden', () => {\n  test('should handle 403 when updating article', async ({ page }) => {\n    const mockArticle = {\n      slug: 'test-article',\n      title: 'Test Article',\n      description: 'Test description',\n      body: 'Test body',\n      tagList: [],\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n      favorited: false,\n      favoritesCount: 0,\n      author: { username: 'currentuser', bio: '', image: '', following: false },\n    };\n    // Mock user fetch\n    await page.route(`${API_BASE}/user`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          user: { username: 'currentuser', email: 'test@test.com', token: 'fake-token', bio: null, image: null },\n        }),\n      });\n    });\n    // Mock article fetch (GET) and update (PUT with 403)\n    await page.route(`${API_BASE}/articles/test-article`, route => {\n      if (route.request().method() === 'GET') {\n        route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({ article: mockArticle }),\n        });\n      } else if (route.request().method() === 'PUT') {\n        route.fulfill({\n          status: 403,\n          contentType: 'application/json',\n          body: JSON.stringify({ errors: { message: ['You are not authorized to edit this article'] } }),\n        });\n      } else {\n        route.continue();\n      }\n    });\n    await page.goto('/');\n    await setFakeAuthToken(page);\n    await page.goto('/editor/test-article');\n    // Wait for form to load\n    await expect(page.locator('input[name=\"title\"]')).toHaveValue('Test Article');\n    // Try to update\n    await page.click('button:has-text(\"Publish\")');\n    // Should show error message\n    await expect(page.locator('.error-messages')).toBeVisible();\n    await expect(page.locator('input[name=\"title\"]')).toBeVisible();\n  });\n\n  test('should handle 403 when deleting another users comment', async ({ page }) => {\n    const mockArticle = {\n      slug: 'test-article',\n      title: 'Test Article',\n      description: 'Test description',\n      body: 'Test body',\n      tagList: [],\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n      favorited: false,\n      favoritesCount: 0,\n      author: { username: 'otheruser', bio: '', image: '', following: false },\n    };\n    const mockComment = {\n      id: 1,\n      body: 'This is a comment',\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n      author: { username: 'currentuser', bio: '', image: '', following: false },\n    };\n    // Mock user fetch\n    await page.route(`${API_BASE}/user`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          user: { username: 'currentuser', email: 'test@test.com', token: 'fake-token', bio: null, image: null },\n        }),\n      });\n    });\n    // Mock article fetch\n    await page.route(`${API_BASE}/articles/test-article`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ article: mockArticle }),\n      });\n    });\n    // Mock comments fetch (GET) and delete (DELETE with 403)\n    await page.route(`${API_BASE}/articles/test-article/comments`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ comments: [mockComment] }),\n      });\n    });\n    await page.route(`${API_BASE}/articles/test-article/comments/1`, route => {\n      if (route.request().method() === 'DELETE') {\n        route.fulfill({\n          status: 403,\n          contentType: 'application/json',\n          body: JSON.stringify({ errors: { message: ['You are not authorized to delete this comment'] } }),\n        });\n      } else {\n        route.continue();\n      }\n    });\n    await page.goto('/');\n    await setFakeAuthToken(page);\n    await page.goto('/article/test-article');\n    // Wait for comment to be visible\n    await expect(page.locator('.card-block:has-text(\"This is a comment\")')).toBeVisible();\n    // Click delete button on the comment (delete button is in card-footer, sibling of card-block)\n    await page.locator('.card:has-text(\"This is a comment\")').locator('i.ion-trash-a').click();\n    // Comment should still be visible (delete failed)\n    await expect(page.locator('.card-block:has-text(\"This is a comment\")')).toBeVisible();\n    // Error message should be displayed\n    await expect(page.locator('.error-messages').last()).toBeVisible();\n    // Article content should still be visible\n    await expect(page.locator('.article-content')).toBeVisible();\n  });\n\n  test('should handle 403 when following user you are blocked by', async ({ page }) => {\n    const mockProfile = {\n      username: 'blockeduser',\n      bio: 'This user blocked you',\n      image: '',\n      following: false,\n    };\n    // Mock user fetch\n    await page.route(`${API_BASE}/user`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          user: { username: 'currentuser', email: 'test@test.com', token: 'fake-token', bio: null, image: null },\n        }),\n      });\n    });\n    // Mock profile fetch\n    await page.route(`${API_BASE}/profiles/blockeduser`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ profile: mockProfile }),\n      });\n    });\n    // Mock articles for profile\n    await page.route(`${API_BASE}/articles?author=blockeduser*`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ articles: [], articlesCount: 0 }),\n      });\n    });\n    // Mock follow with 403\n    await page.route(`${API_BASE}/profiles/blockeduser/follow`, route => {\n      route.fulfill({\n        status: 403,\n        contentType: 'application/json',\n        body: JSON.stringify({ errors: { message: ['You cannot follow this user'] } }),\n      });\n    });\n    await page.goto('/');\n    await setFakeAuthToken(page);\n    await page.goto('/profile/blockeduser');\n    // Wait for profile to load\n    await expect(page.locator('button:has-text(\"Follow\")')).toBeVisible();\n    // Try to follow\n    await page.click('button:has-text(\"Follow\")');\n    // App should not crash, button should still show Follow (not Unfollow)\n    await expect(page.locator('button:has-text(\"Follow\")')).toBeVisible();\n    await expect(page.locator('.user-info')).toBeVisible();\n  });\n});\n\ntest.describe('Error Handling - 500 Internal Server Error', () => {\n  test('should handle 500 on articles feed load', async ({ page }) => {\n    await mockApiError(page, '/articles*', 500, {\n      errors: { server: ['Internal server error'] },\n    });\n    await page.goto('/');\n    // App should not crash - navbar and banner should still be visible\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    await expect(page.locator('.navbar-brand')).toBeVisible();\n    await expect(page.locator('.banner')).toBeVisible();\n  });\n\n  test('should handle 500 on tags load', async ({ page }) => {\n    // Mock articles as successful\n    await page.route(`${API_BASE}/articles*`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ articles: [], articlesCount: 0 }),\n      });\n    });\n    await mockApiError(page, '/tags', 500, {\n      errors: { server: ['Database connection failed'] },\n    });\n    await page.goto('/');\n    // App should load without tags, not crash\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    await expect(page.locator('.banner')).toBeVisible();\n    // Feed toggle should still be functional\n    await expect(page.locator('.feed-toggle')).toBeVisible();\n  });\n\n  test('should handle network error on tags load', async ({ page }) => {\n    await page.route(`${API_BASE}/articles*`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ articles: [], articlesCount: 0 }),\n      });\n    });\n    await page.route(`${API_BASE}/tags`, route => {\n      route.abort('internetdisconnected');\n    });\n    await page.goto('/');\n    // App should load without tags, not crash\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    await expect(page.locator('.banner')).toBeVisible();\n    // Feed toggle should still be functional\n    await expect(page.locator('.feed-toggle')).toBeVisible();\n  });\n\n  test('should handle 500 on user profile load', async ({ page }) => {\n    await mockApiError(page, '/profiles/*', 500, {\n      errors: { server: ['Failed to fetch profile'] },\n    });\n    await page.goto('/profile/someuser');\n    // Should show error state or fallback, not crash\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    // Profile container should exist (even if empty)\n    await expect(page.locator('.profile-page, .user-info')).toBeVisible();\n  });\n\n  test('should handle network error on user profile load', async ({ page }) => {\n    await page.route(`${API_BASE}/profiles/*`, route => {\n      route.abort('internetdisconnected');\n    });\n    await page.goto('/profile/someuser');\n    // Should not crash\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    // Profile container should exist (even if empty)\n    await expect(page.locator('.profile-page, .user-info')).toBeVisible();\n  });\n\n  test('should handle 500 on article detail load', async ({ page }) => {\n    await mockApiError(page, '/articles/some-article', 500, {\n      errors: { server: ['Failed to fetch article'] },\n    });\n    await page.goto('/article/some-article');\n    // App should not crash\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    // Article page container should exist\n    await expect(page.locator('.article-page')).toBeVisible();\n  });\n\n  test('should handle network error on article detail load', async ({ page }) => {\n    await page.route(`${API_BASE}/articles/some-article`, route => {\n      route.abort('internetdisconnected');\n    });\n    await page.goto('/article/some-article');\n    // App should not crash\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    // Article page container should exist\n    await expect(page.locator('.article-page')).toBeVisible();\n  });\n\n  test('should handle 500 when submitting settings', async ({ page }) => {\n    // Mock user fetch as successful\n    await page.route(`${API_BASE}/user`, route => {\n      if (route.request().method() === 'GET') {\n        route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({\n            user: { username: 'testuser', email: 'test@test.com', token: 'fake-token', bio: 'bio', image: null },\n          }),\n        });\n      } else if (route.request().method() === 'PUT') {\n        route.fulfill({\n          status: 500,\n          contentType: 'application/json',\n          body: JSON.stringify({ errors: { server: ['Failed to save settings'] } }),\n        });\n      }\n    });\n    await page.goto('/');\n    await setFakeAuthToken(page);\n    await page.goto('/settings');\n    // Wait for form to load\n    await expect(page.locator('input[name=\"email\"]')).toBeVisible();\n    // Try to submit\n    await page.click('button[type=\"submit\"]');\n    // Should show error, not crash\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    // Form should still be usable\n    await expect(page.locator('input[name=\"email\"]')).toBeVisible();\n  });\n\n  test('should handle intermittent 500 errors gracefully', async ({ page }) => {\n    let requestCount = 0;\n    // First request fails, second succeeds\n    await page.route(`${API_BASE}/articles*`, route => {\n      requestCount++;\n      if (requestCount === 1) {\n        route.fulfill({\n          status: 500,\n          contentType: 'application/json',\n          body: JSON.stringify({ errors: { server: ['Temporary failure'] } }),\n        });\n      } else {\n        route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({ articles: [], articlesCount: 0 }),\n        });\n      }\n    });\n    await page.goto('/');\n    // App should still be functional after error\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    await expect(page.locator('.banner')).toBeVisible();\n  });\n});\n\ntest.describe('Error Handling - Network Errors', () => {\n  test('should handle network timeout', async ({ page }) => {\n    await page.route(`${API_BASE}/articles*`, route => {\n      // Simulate timeout by not responding\n      route.abort('timedout');\n    });\n    await page.goto('/');\n    // App should not crash\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    await expect(page.locator('.banner')).toBeVisible();\n  });\n\n  test('should handle connection refused', async ({ page }) => {\n    await page.route(`${API_BASE}/articles*`, route => {\n      route.abort('connectionrefused');\n    });\n    await page.goto('/');\n    // App should not crash\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    await expect(page.locator('.banner')).toBeVisible();\n  });\n\n  test('should show error message on settings form when network fails', async ({ page }) => {\n    // Mock GET /user to simulate logged-in state\n    await page.route(`${API_BASE}/user`, route => {\n      if (route.request().method() === 'GET') {\n        route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({\n            user: {\n              email: 'test@example.com',\n              username: 'testuser',\n              bio: 'Test bio',\n              image: '',\n              token: 'fake-token',\n            },\n          }),\n        });\n      } else if (route.request().method() === 'PUT') {\n        // Simulate network error on form submission\n        route.abort('internetdisconnected');\n      } else {\n        route.continue();\n      }\n    });\n    // Set token and go to settings\n    await page.goto('/');\n    await page.evaluate(() => {\n      localStorage.setItem('jwtToken', 'fake-token');\n    });\n    await page.goto('/settings');\n    await expect(page.locator('button:has-text(\"Update Settings\")')).toBeVisible();\n    // Submit the form\n    await page.click('button:has-text(\"Update Settings\")');\n    // Should show network error message\n    await expect(page.locator('.error-messages')).toBeVisible();\n    await expect(page.locator('.error-messages')).toContainText('Unable to connect');\n    // Form should still be usable\n    await expect(page.locator('button:has-text(\"Update Settings\")')).toBeVisible();\n  });\n\n  test('should show error message on login form when network fails', async ({ page }) => {\n    await page.route(`${API_BASE}/users/login`, route => {\n      route.abort('internetdisconnected');\n    });\n    await page.goto('/login');\n    await page.fill('input[name=\"email\"]', 'test@example.com');\n    await page.fill('input[name=\"password\"]', 'password123');\n    await page.click('button[type=\"submit\"]');\n    await expect(page.locator('.error-messages')).toBeVisible();\n    await expect(page.locator('.error-messages')).toContainText('Unable to connect');\n    // Form should still be usable\n    await expect(page.locator('button[type=\"submit\"]')).toBeVisible();\n  });\n\n  test('should show error message on register form when network fails', async ({ page }) => {\n    await page.route(`${API_BASE}/users`, route => {\n      route.abort('internetdisconnected');\n    });\n    await page.goto('/register');\n    await page.fill('input[name=\"username\"]', 'testuser');\n    await page.fill('input[name=\"email\"]', 'test@example.com');\n    await page.fill('input[name=\"password\"]', 'password123');\n    await page.click('button[type=\"submit\"]');\n    await expect(page.locator('.error-messages')).toBeVisible();\n    await expect(page.locator('.error-messages')).toContainText('Unable to connect');\n    // Form should still be usable\n    await expect(page.locator('button[type=\"submit\"]')).toBeVisible();\n  });\n\n  test('should show error message on create article form when network fails', async ({ page }) => {\n    // Mock logged-in state\n    await page.route(`${API_BASE}/user`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          user: { email: 'test@example.com', username: 'testuser', bio: '', image: '', token: 'fake-token' },\n        }),\n      });\n    });\n    // Network error on article creation\n    await page.route(`${API_BASE}/articles/`, route => {\n      route.abort('internetdisconnected');\n    });\n    await page.goto('/');\n    await page.evaluate(() => localStorage.setItem('jwtToken', 'fake-token'));\n    await page.goto('/editor');\n    await page.fill('input[name=\"title\"]', 'Test Article');\n    await page.fill('input[name=\"description\"]', 'Test description');\n    await page.fill('textarea[name=\"body\"]', 'Test body content');\n    await page.click('button:has-text(\"Publish Article\")');\n    await expect(page.locator('.error-messages')).toBeVisible();\n    await expect(page.locator('.error-messages')).toContainText('Unable to connect');\n    // Form should still be usable\n    await expect(page.locator('button:has-text(\"Publish Article\")')).toBeVisible();\n  });\n\n  test('should show error message on update article form when network fails', async ({ page }) => {\n    const mockArticle = {\n      slug: 'test-article',\n      title: 'Test Article',\n      description: 'Test description',\n      body: 'Test body',\n      tagList: [],\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n      favorited: false,\n      favoritesCount: 0,\n      author: { username: 'testuser', bio: '', image: '', following: false },\n    };\n    // Mock logged-in state\n    await page.route(`${API_BASE}/user`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          user: { email: 'test@example.com', username: 'testuser', bio: '', image: '', token: 'fake-token' },\n        }),\n      });\n    });\n    // Mock article fetch\n    await page.route(`${API_BASE}/articles/test-article`, route => {\n      if (route.request().method() === 'GET') {\n        route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({ article: mockArticle }),\n        });\n      } else if (route.request().method() === 'PUT') {\n        route.abort('internetdisconnected');\n      } else {\n        route.continue();\n      }\n    });\n    await page.goto('/');\n    await page.evaluate(() => localStorage.setItem('jwtToken', 'fake-token'));\n    await page.goto('/editor/test-article');\n    await expect(page.locator('input[name=\"title\"]')).toHaveValue('Test Article');\n    await page.click('button:has-text(\"Publish Article\")');\n    await expect(page.locator('.error-messages')).toBeVisible();\n    await expect(page.locator('.error-messages')).toContainText('Unable to connect');\n    // Form should still be usable\n    await expect(page.locator('button:has-text(\"Publish Article\")')).toBeVisible();\n  });\n\n  test('should show error message when adding comment fails due to network', async ({ page }) => {\n    const mockArticle = {\n      slug: 'test-article',\n      title: 'Test Article',\n      description: 'Test description',\n      body: 'Test body',\n      tagList: [],\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n      favorited: false,\n      favoritesCount: 0,\n      author: { username: 'otheruser', bio: '', image: '', following: false },\n    };\n    // Mock logged-in state\n    await page.route(`${API_BASE}/user`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          user: { email: 'test@example.com', username: 'testuser', bio: '', image: '', token: 'fake-token' },\n        }),\n      });\n    });\n    // Mock article fetch\n    await page.route(`${API_BASE}/articles/test-article`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ article: mockArticle }),\n      });\n    });\n    // Mock comments fetch (empty) and POST (network error)\n    await page.route(`${API_BASE}/articles/test-article/comments`, route => {\n      if (route.request().method() === 'GET') {\n        route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({ comments: [] }),\n        });\n      } else if (route.request().method() === 'POST') {\n        route.abort('internetdisconnected');\n      } else {\n        route.continue();\n      }\n    });\n    await page.goto('/');\n    await page.evaluate(() => localStorage.setItem('jwtToken', 'fake-token'));\n    await page.goto('/article/test-article');\n    await page.fill('textarea[placeholder=\"Write a comment...\"]', 'Test comment');\n    await page.click('button:has-text(\"Post Comment\")');\n    await expect(page.locator('.error-messages').first()).toBeVisible();\n    await expect(page.locator('.error-messages').first()).toContainText('Unable to connect');\n    // Article content should still be visible\n    await expect(page.locator('.article-content')).toBeVisible();\n  });\n\n  test('should handle network error when favoriting article', async ({ page }) => {\n    const mockArticle = {\n      slug: 'test-article',\n      title: 'Test Article',\n      description: 'Test description',\n      body: 'Test body',\n      tagList: [],\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n      favorited: false,\n      favoritesCount: 0,\n      author: { username: 'otheruser', bio: '', image: '', following: false },\n    };\n    // Mock logged-in state\n    await page.route(`${API_BASE}/user`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          user: { email: 'test@example.com', username: 'testuser', bio: '', image: '', token: 'fake-token' },\n        }),\n      });\n    });\n    // Mock article fetch\n    await page.route(`${API_BASE}/articles/test-article`, route => {\n      if (route.request().method() === 'GET') {\n        route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({ article: mockArticle }),\n        });\n      } else {\n        route.continue();\n      }\n    });\n    // Mock comments\n    await page.route(`${API_BASE}/articles/test-article/comments`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ comments: [] }),\n      });\n    });\n    // Network error on favorite\n    await page.route(`${API_BASE}/articles/test-article/favorite`, route => {\n      route.abort('internetdisconnected');\n    });\n    await page.goto('/');\n    await page.evaluate(() => localStorage.setItem('jwtToken', 'fake-token'));\n    await page.goto('/article/test-article');\n    // Click favorite button (first one - there are 2 on the page)\n    await page.locator('button:has-text(\"Favorite Article\")').first().click();\n    // App should not crash - button should still be visible\n    await expect(page.locator('button:has-text(\"Favorite Article\")').first()).toBeVisible();\n    // Article content should still be visible\n    await expect(page.locator('.article-content')).toBeVisible();\n  });\n\n  test('should handle network error when following user', async ({ page }) => {\n    const mockProfile = {\n      username: 'otheruser',\n      bio: 'Test bio',\n      image: '',\n      following: false,\n    };\n    // Mock logged-in state\n    await page.route(`${API_BASE}/user`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          user: { email: 'test@example.com', username: 'testuser', bio: '', image: '', token: 'fake-token' },\n        }),\n      });\n    });\n    // Mock profile fetch\n    await page.route(`${API_BASE}/profiles/otheruser`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ profile: mockProfile }),\n      });\n    });\n    // Mock articles for profile\n    await page.route(`${API_BASE}/articles?author=otheruser*`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ articles: [], articlesCount: 0 }),\n      });\n    });\n    // Network error on follow\n    await page.route(`${API_BASE}/profiles/otheruser/follow`, route => {\n      route.abort('internetdisconnected');\n    });\n    await page.goto('/');\n    await page.evaluate(() => localStorage.setItem('jwtToken', 'fake-token'));\n    await page.goto('/profile/otheruser');\n    // Click follow button\n    await page.click('button:has-text(\"Follow\")');\n    // App should not crash - button should still be visible\n    await expect(page.locator('button:has-text(\"Follow\")')).toBeVisible();\n    // Profile info should still be visible\n    await expect(page.locator('.user-info')).toBeVisible();\n  });\n});\n\ntest.describe('Error Handling - Edge Cases', () => {\n  test('should handle malformed JSON response', async ({ page }) => {\n    await page.route(`${API_BASE}/articles*`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: '{ invalid json }}}',\n      });\n    });\n    await page.goto('/');\n    // App should not crash on malformed response\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    await expect(page.locator('.banner')).toBeVisible();\n  });\n\n  test('should handle empty response body', async ({ page }) => {\n    await page.route(`${API_BASE}/articles*`, route => {\n      route.fulfill({\n        status: 204, // No Content is more appropriate for empty body\n        contentType: 'application/json',\n        body: '',\n      });\n    });\n    await page.goto('/');\n    // App should handle empty response - banner and navbar should be visible\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    await expect(page.locator('.banner')).toBeVisible();\n  });\n\n  test('should handle 404 for non-existent article', async ({ page }) => {\n    await mockApiError(page, '/articles/non-existent-slug', 404, {\n      errors: { article: ['not found'] },\n    });\n    await page.goto('/article/non-existent-slug');\n    // Should show appropriate message, not crash\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    // Article page container should still render\n    await expect(page.locator('.article-page')).toBeVisible();\n  });\n\n  test('should handle 404 for non-existent profile', async ({ page }) => {\n    await mockApiError(page, '/profiles/nonexistentuser', 404, {\n      errors: { profile: ['not found'] },\n    });\n    await page.goto('/profile/nonexistentuser');\n    // Should show appropriate message, not crash\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    // Profile page container should still render\n    await expect(page.locator('.profile-page, .user-info')).toBeVisible();\n  });\n});\n"
  },
  {
    "path": "specs/e2e/health.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { API_MODE } from './helpers/config';\n\ntest.describe('Health Checks', () => {\n  test('app should load successfully', async ({ page }) => {\n    await page.goto('/');\n\n    // Should see the app brand/logo\n    await expect(page.locator('a.navbar-brand')).toBeVisible({ timeout: 10000 });\n\n    // Should see navigation\n    await expect(page.locator('nav.navbar')).toBeVisible();\n  });\n\n  test('API should be accessible', async ({ request }) => {\n    test.skip(!API_MODE, 'API-only: direct API endpoint check');\n    const response = await request.get('https://api.realworld.show/api/tags');\n    expect(response.ok()).toBeTruthy();\n  });\n\n  test('can navigate to login page', async ({ page }) => {\n    await page.goto('/login');\n\n    // Should see login form\n    await expect(page.locator('h1')).toContainText('Sign in', { timeout: 10000 });\n    await expect(page.locator('input[name=\"email\"]')).toBeVisible();\n  });\n\n  test('can navigate to register page', async ({ page }) => {\n    await page.goto('/register');\n\n    // Should see register form\n    await expect(page.locator('h1')).toContainText('Sign up', { timeout: 10000 });\n    await expect(page.locator('input[name=\"username\"]')).toBeVisible();\n  });\n});\n"
  },
  {
    "path": "specs/e2e/helpers/api.ts",
    "content": "import { APIRequestContext } from '@playwright/test';\nimport { API_BASE } from './config';\n\nexport interface UserCredentials {\n  email: string;\n  password: string;\n  username: string;\n}\n\nexport async function registerUserViaAPI(request: APIRequestContext, user: UserCredentials): Promise<string> {\n  const response = await request.post(`${API_BASE}/users`, {\n    data: {\n      user: {\n        username: user.username,\n        email: user.email,\n        password: user.password,\n      },\n    },\n  });\n  if (!response.ok()) {\n    throw new Error(`Failed to register user: ${response.status()}`);\n  }\n  const data = await response.json();\n  return data.user.token;\n}\n\nexport async function loginUserViaAPI(request: APIRequestContext, email: string, password: string): Promise<string> {\n  const response = await request.post(`${API_BASE}/users/login`, {\n    data: {\n      user: {\n        email,\n        password,\n      },\n    },\n  });\n  if (!response.ok()) {\n    throw new Error(`Failed to login: ${response.status()}`);\n  }\n  const data = await response.json();\n  return data.user.token;\n}\n\nexport async function createArticleViaAPI(\n  request: APIRequestContext,\n  token: string,\n  article: { title: string; description: string; body: string; tagList?: string[] },\n): Promise<string> {\n  const response = await request.post(`${API_BASE}/articles`, {\n    headers: {\n      Authorization: `Token ${token}`,\n    },\n    data: {\n      article: {\n        title: article.title,\n        description: article.description,\n        body: article.body,\n        tagList: article.tagList || [],\n      },\n    },\n  });\n  if (!response.ok()) {\n    throw new Error(`Failed to create article: ${response.status()}`);\n  }\n  const data = await response.json();\n  return data.article.slug;\n}\n\nexport async function updateUserViaAPI(\n  request: APIRequestContext,\n  token: string,\n  updates: { image?: string; bio?: string; username?: string; email?: string },\n): Promise<void> {\n  const response = await request.put(`${API_BASE}/user`, {\n    headers: {\n      Authorization: `Token ${token}`,\n    },\n    data: {\n      user: updates,\n    },\n  });\n  if (!response.ok()) {\n    throw new Error(`Failed to update user: ${response.status()}`);\n  }\n}\n\nexport async function createManyArticles(\n  request: APIRequestContext,\n  token: string,\n  count: number,\n  tag: string = 'paginationtest',\n): Promise<string[]> {\n  const slugs: string[] = [];\n  const uniqueId = `${Date.now()}${Math.random().toString(36).substring(2, 8)}`;\n  for (let i = 0; i < count; i++) {\n    const slug = await createArticleViaAPI(request, token, {\n      title: `Test Article ${uniqueId} Number ${i}`,\n      description: `Description for test article ${i}`,\n      body: `Body content for test article ${i}. Created with ID ${uniqueId}.`,\n      tagList: [tag],\n    });\n    slugs.push(slug);\n    // Small pause between articles to avoid rate limits\n    await new Promise(resolve => setTimeout(resolve, 100));\n  }\n  return slugs;\n}\n"
  },
  {
    "path": "specs/e2e/helpers/articles.ts",
    "content": "import { Page, expect } from '@playwright/test';\n\nexport interface ArticleData {\n  title: string;\n  description: string;\n  body: string;\n  tags?: string[];\n}\n\nexport async function createArticle(page: Page, article: ArticleData, options: { sleepAfter?: number } = {}) {\n  const { sleepAfter = 1 } = options;\n\n  await page.goto('/editor', { waitUntil: 'load' });\n\n  await page.fill('input[name=\"title\"]', article.title);\n  await page.fill('input[name=\"description\"]', article.description);\n  await page.fill('textarea[name=\"body\"]', article.body);\n\n  if (article.tags && article.tags.length > 0) {\n    for (const tag of article.tags) {\n      await page.fill('input[placeholder=\"Enter tags\"]', tag);\n      await page.press('input[placeholder=\"Enter tags\"]', 'Enter');\n    }\n  }\n\n  // Start waiting for navigation before clicking to avoid race condition\n  await Promise.all([page.waitForURL(/\\/article\\/.+/), page.click('button:has-text(\"Publish Article\")')]);\n\n  // Ensure Date.now() advances so the next generateUniqueArticle() gets a distinct timestamp\n  if (sleepAfter > 0) {\n    await new Promise(resolve => setTimeout(resolve, sleepAfter));\n  }\n}\n\nexport async function editArticle(page: Page, slug: string, updates: Partial<ArticleData>) {\n  await page.goto(`/editor/${slug}`, { waitUntil: 'load' });\n\n  // Wait for the API data to populate the form (not just for the input to exist)\n  const titleInput = page.locator('input[name=\"title\"]');\n  await expect(titleInput).not.toHaveValue('', { timeout: 10000 });\n\n  if (updates.title) {\n    await page.fill('input[name=\"title\"]', '');\n    await page.fill('input[name=\"title\"]', updates.title);\n  }\n  if (updates.description) {\n    await page.fill('input[name=\"description\"]', '');\n    await page.fill('input[name=\"description\"]', updates.description);\n  }\n  if (updates.body) {\n    await page.fill('textarea[name=\"body\"]', '');\n    await page.fill('textarea[name=\"body\"]', updates.body);\n  }\n\n  await Promise.all([page.waitForURL(/\\/article\\/.+/), page.click('button:has-text(\"Publish Article\")')]);\n}\n\nexport async function deleteArticle(page: Page) {\n  // Assumes we're already on the article page\n  await Promise.all([page.waitForURL('/'), page.click('button:has-text(\"Delete Article\")')]);\n}\n\nexport async function favoriteArticle(page: Page) {\n  await page.click('button.btn-outline-primary:has-text(\"Favorite\")');\n  // Wait for the button to update to \"Unfavorite\"\n  await page.waitForSelector('button.btn-primary:has-text(\"Unfavorite\")');\n}\n\nexport async function unfavoriteArticle(page: Page) {\n  await page.click('button.btn-primary:has-text(\"Unfavorite\")');\n  // Wait for the button to update back to \"Favorite\"\n  await page.waitForSelector('button.btn-outline-primary:has-text(\"Favorite\")');\n}\n\nexport function generateUniqueArticle(): ArticleData {\n  const timestamp = Date.now();\n  return {\n    title: `Test Article ${timestamp}`,\n    description: `Description for test article ${timestamp}`,\n    body: `This is the body content for test article created at ${timestamp}. It contains enough text to be meaningful.`,\n    tags: ['test', 'playwright'],\n  };\n}\n"
  },
  {
    "path": "specs/e2e/helpers/auth.ts",
    "content": "import { Page } from '@playwright/test';\n\nexport async function register(page: Page, username: string, email: string, password: string) {\n  await page.goto('/register', { waitUntil: 'load' });\n  await page.fill('input[name=\"username\"]', username);\n  await page.fill('input[name=\"email\"]', email);\n  await page.fill('input[name=\"password\"]', password);\n\n  // Wait for navigation to complete or error to appear\n  try {\n    await Promise.all([page.waitForURL('/'), page.click('button[type=\"submit\"]')]);\n  } catch (error) {\n    // If navigation fails, check for errors\n    const errorMsg = await page\n      .locator('.error-messages')\n      .textContent()\n      .catch(() => '');\n    if (errorMsg) {\n      throw new Error(`Registration failed: ${errorMsg}`);\n    }\n    throw error;\n  }\n}\n\nexport async function login(page: Page, email: string, password: string) {\n  await page.goto('/login', { waitUntil: 'load' });\n  await page.fill('input[name=\"email\"]', email);\n  await page.fill('input[name=\"password\"]', password);\n\n  // Wait for navigation to complete or error to appear\n  try {\n    await Promise.all([page.waitForURL('/'), page.click('button[type=\"submit\"]')]);\n  } catch (error) {\n    // If navigation fails, check for errors\n    const errorMsg = await page\n      .locator('.error-messages')\n      .textContent()\n      .catch(() => '');\n    if (errorMsg) {\n      throw new Error(`Login failed: ${errorMsg}`);\n    }\n    throw error;\n  }\n}\n\nexport async function logout(page: Page) {\n  await page.click('a[href=\"/settings\"]');\n  await Promise.all([page.waitForURL('/'), page.click('button:has-text(\"Or click here to logout\")')]);\n}\n\nexport function generateUniqueUser() {\n  const timestamp = Date.now();\n  return {\n    username: `testuser${timestamp}`,\n    email: `test${timestamp}@example.com`,\n    password: 'password123',\n  };\n}\n"
  },
  {
    "path": "specs/e2e/helpers/comments.ts",
    "content": "import { Page } from '@playwright/test';\n\nexport async function addComment(page: Page, commentText: string) {\n  // Assumes we're on an article page - wait for comment form to be ready\n  await page.waitForSelector('textarea[placeholder=\"Write a comment...\"]', { timeout: 10000 });\n  await page.fill('textarea[placeholder=\"Write a comment...\"]', commentText);\n\n  // Get initial comment count (exclude the comment form itself)\n  const initialCount = await page.locator('.card:not(.comment-form) .card-block').count();\n\n  await page.click('button:has-text(\"Post Comment\")');\n\n  // Wait for a new comment to appear (count should increase by 1)\n  await page.waitForFunction(\n    expectedCount => document.querySelectorAll('.card:not(.comment-form) .card-block').length >= expectedCount,\n    initialCount + 1,\n    { timeout: 5000 },\n  );\n}\n\nexport async function deleteComment(page: Page, commentText: string) {\n  // Find the comment card containing the text and click its delete button\n  const commentCard = page.locator('.card', { has: page.locator(`text=\"${commentText}\"`) });\n  await commentCard.locator('span.mod-options i.ion-trash-a').click();\n\n  // Wait for comment to disappear\n}\n\nexport async function getCommentCount(page: Page): Promise<number> {\n  // Exclude the comment form which also has .card .card-block structure\n  const comments = await page.locator('.card:not(.comment-form) .card-block').count();\n  return comments;\n}\n"
  },
  {
    "path": "specs/e2e/helpers/config.ts",
    "content": "export const API_MODE = process.env.API_MODE?.toLowerCase() !== 'false';\nexport const API_BASE = process.env.API_BASE || 'https://api.realworld.show/api';\n"
  },
  {
    "path": "specs/e2e/helpers/debug.ts",
    "content": "import { Page } from '@playwright/test';\n\n/**\n * Debug interface helpers for e2e tests.\n *\n * These helpers interact with window.__conduit_debug__, a standardized interface\n * that RealWorld implementations should expose for testing purposes.\n *\n * ## Implementation Guide\n *\n * Implementations should expose this interface on window.__conduit_debug__\n *\n * ```typescript\n * interface ConduitDebug {\n *   getToken: () => string | null;\n *   getAuthState: () => 'authenticated' | 'unauthenticated' | 'unavailable' | 'loading';\n *   getCurrentUser: () => User | null;\n * }\n * ```\n */\n\nexport type AuthState = 'authenticated' | 'unauthenticated' | 'unavailable' | 'loading';\n\nexport interface User {\n  username: string;\n  email: string;\n  bio: string | null;\n  image: string | null;\n  token: string;\n}\n\n/**\n * Get the current JWT token from the app's debug interface.\n * Returns null if no token is set or debug interface is not available.\n */\nexport async function getToken(page: Page): Promise<string | null> {\n  return page.evaluate(() => window.__conduit_debug__?.getToken() ?? null);\n}\n\n/**\n * Get the current authentication state from the app's debug interface.\n * Returns undefined if debug interface is not available.\n */\nexport async function getAuthState(page: Page): Promise<AuthState | undefined> {\n  return page.evaluate(() => window.__conduit_debug__?.getAuthState());\n}\n\n/**\n * Get the current user from the app's debug interface.\n * Returns null if not authenticated or debug interface is not available.\n */\nexport async function getCurrentUser(page: Page): Promise<User | null> {\n  return page.evaluate(() => window.__conduit_debug__?.getCurrentUser() ?? null);\n}\n\n/**\n * Wait for a specific auth state to be reached.\n * Useful for waiting after login/logout operations.\n */\nexport async function waitForAuthState(\n  page: Page,\n  expectedState: AuthState,\n  options: { timeout?: number } = {},\n): Promise<void> {\n  const timeout = options.timeout ?? 5000;\n  await page.waitForFunction(state => window.__conduit_debug__?.getAuthState() === state, expectedState, { timeout });\n}\n\n/**\n * Check if the debug interface is available.\n * Can be used to skip tests if implementation doesn't support it.\n */\nexport async function isDebugInterfaceAvailable(page: Page): Promise<boolean> {\n  return page.evaluate(() => typeof window.__conduit_debug__ !== 'undefined');\n}\n"
  },
  {
    "path": "specs/e2e/helpers/profile.ts",
    "content": "import { Page } from '@playwright/test';\nimport { API_MODE } from './config';\n\nexport async function followUser(page: Page, username: string) {\n  await page.goto(`/profile/${username}`, { waitUntil: 'load' });\n  // Wait for profile page to load and Follow button to appear\n  await page.waitForSelector('button:has-text(\"Follow\")', { timeout: 10000 });\n  await page.click('button:has-text(\"Follow\")');\n  // Wait for button to update\n  await page.waitForSelector('button:has-text(\"Unfollow\")', { timeout: 5000 });\n}\n\nexport async function unfollowUser(page: Page, username: string) {\n  await page.goto(`/profile/${username}`, { waitUntil: 'load' });\n  // Wait for profile page to load and Unfollow button to appear\n  await page.waitForSelector('button:has-text(\"Unfollow\")', { timeout: 10000 });\n  await page.click('button:has-text(\"Unfollow\")');\n  // Wait for button to update\n  await page.waitForSelector('button:has-text(\"Follow\")', { timeout: 5000 });\n}\n\nexport async function updateProfile(\n  page: Page,\n  updates: {\n    image?: string;\n    username?: string;\n    bio?: string;\n    email?: string;\n    password?: string;\n  },\n) {\n  await page.goto('/settings', { waitUntil: 'load' });\n\n  if (updates.image !== undefined) {\n    await page.fill('input[name=\"image\"]', updates.image);\n  }\n  if (updates.username) {\n    await page.fill('input[name=\"username\"]', updates.username);\n  }\n  if (updates.bio !== undefined) {\n    await page.fill('textarea[name=\"bio\"]', updates.bio);\n  }\n  if (updates.email) {\n    await page.fill('input[name=\"email\"]', updates.email);\n  }\n  if (updates.password) {\n    await page.fill('input[name=\"password\"]', updates.password);\n  }\n\n  if (API_MODE) {\n    // Click submit and wait for API call to complete, then navigation\n    await Promise.all([\n      page.waitForResponse(response => response.url().includes('/user') && response.request().method() === 'PUT'),\n      page.waitForURL(url => !url.toString().includes('/settings')),\n      page.click('button[type=\"submit\"]'),\n    ]);\n  } else {\n    // Click submit and wait for navigation away from settings\n    await Promise.all([\n      page.waitForURL(url => !url.toString().includes('/settings')),\n      page.click('button[type=\"submit\"]'),\n    ]);\n  }\n}\n"
  },
  {
    "path": "specs/e2e/helpers/setup.ts",
    "content": "import { Page, Browser, APIRequestContext } from '@playwright/test';\nimport { register, generateUniqueUser } from './auth';\nimport { registerUserViaAPI, createManyArticles as createManyArticlesViaAPI } from './api';\nimport { API_MODE } from './config';\n\nexport interface UserCredentials {\n  username: string;\n  email: string;\n  password: string;\n}\n\n/**\n * Create a user in an isolated browser context (for non-API mode)\n * or via API (default).\n */\nexport async function createUserInIsolation(\n  browser: Browser,\n  user?: UserCredentials,\n): Promise<UserCredentials> {\n  const u = user || generateUniqueUser();\n  const ctx = await browser.newContext();\n  const page = await ctx.newPage();\n  await register(page, u.username, u.email, u.password);\n  await ctx.close();\n  return u;\n}\n\n/**\n * Create many articles for pagination tests.\n * Uses API when available (fast), falls back to form submissions.\n */\nexport async function createManyArticles(\n  page: Page,\n  count: number,\n  tag: string = 'paginationtest',\n  request?: APIRequestContext,\n  token?: string,\n): Promise<void> {\n  if (API_MODE && request && token) {\n    await createManyArticlesViaAPI(request, token, count, tag);\n  } else {\n    // Form-based: fill out the editor for each article\n    const uniqueId = `${Date.now()}${Math.random().toString(36).substring(2, 8)}`;\n    for (let i = 0; i < count; i++) {\n      await page.goto('/editor', { waitUntil: 'load' });\n      await page.fill('input[name=\"title\"]', `Test Article ${uniqueId} Number ${i}`);\n      await page.fill('input[name=\"description\"]', `Description for test article ${i}`);\n      await page.fill('textarea[name=\"body\"]', `Body content for test article ${i}. Created with ID ${uniqueId}.`);\n      if (API_MODE) {\n        await page.fill('input[placeholder=\"Enter tags\"]', tag);\n        await page.press('input[placeholder=\"Enter tags\"]', 'Enter');\n      } else {\n        await page.fill('input[placeholder=\"Enter tags\"]', tag);\n      }\n      await Promise.all([\n        page.waitForURL(/\\/article\\/.+/),\n        page.click('button:has-text(\"Publish Article\")'),\n      ]);\n    }\n  }\n}\n"
  },
  {
    "path": "specs/e2e/navigation.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { register, login, generateUniqueUser } from './helpers/auth';\nimport { createArticle, generateUniqueArticle } from './helpers/articles';\nimport { registerUserViaAPI, createManyArticles } from './helpers/api';\nimport { API_MODE } from './helpers/config';\n\ntest.describe('Navigation and Filtering', () => {\n  test.afterEach(async ({ context }) => {\n    // Close the browser context to ensure complete isolation between tests.\n    // This releases browser instances, network connections, and other resources.\n    await context.close();\n    // Wait 1000ms to allow async cleanup operations to complete.\n    // Without this delay, running 6+ tests in sequence causes flaky failures\n    // due to resource exhaustion (network connections, file descriptors, etc).\n    // This timing issue manifests as timeouts when loading article pages.\n    // This will be investigated and fixed later.\n    await new Promise(resolve => setTimeout(resolve, 1000));\n  });\n\n  test('should navigate through main pages when logged out', async ({ page }) => {\n    await page.goto('/');\n\n    // Should see home page\n    await expect(page.locator('a.navbar-brand')).toBeVisible();\n\n    // Click Sign in\n    await page.click('a[href=\"/login\"]');\n    await expect(page).toHaveURL('/login');\n\n    // Click Sign up\n    await page.click('a[href=\"/register\"]');\n    await expect(page).toHaveURL('/register');\n\n    // Click Home\n    await page.click('a.navbar-brand');\n    await expect(page).toHaveURL('/');\n  });\n\n  test('should navigate through main pages when logged in', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n\n    // Should see authenticated navigation\n    await expect(page.locator('nav a[href=\"/\"]').first()).toBeVisible();\n    await expect(page.locator('a[href=\"/editor\"]')).toBeVisible();\n    await expect(page.locator('a[href=\"/settings\"]')).toBeVisible();\n    await expect(page.locator(`a[href=\"/profile/${user.username}\"]`)).toBeVisible();\n\n    // Navigate to editor\n    await page.click('a[href=\"/editor\"]');\n    await expect(page).toHaveURL('/editor');\n\n    // Navigate to settings\n    await page.click('a[href=\"/settings\"]');\n    await expect(page).toHaveURL('/settings');\n\n    // Navigate to profile\n    await page.click(`a[href=\"/profile/${user.username}\"]`);\n    await expect(page).toHaveURL(`/profile/${user.username}`);\n  });\n\n  test('should filter articles by tag', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n\n    // Create article with specific tag\n    const article = {\n      ...generateUniqueArticle(),\n      tags: ['playwright-test', 'automation'],\n    };\n\n    await createArticle(page, article);\n\n    // Go to home and wait for it to load\n    await page.goto('/', { waitUntil: 'load' });\n\n    // Wait for the sidebar to be visible\n    await page.waitForSelector('.sidebar .tag-list', { timeout: 3000 });\n\n    // Wait for the specific tag to appear in Popular Tags sidebar (or use first available tag)\n    // Note: Custom tags might not appear immediately in Popular Tags\n    const tagExists = (await page.locator('.sidebar .tag-list .tag-pill:has-text(\"playwright-test\")').count()) > 0;\n\n    if (tagExists) {\n      // Click on our custom tag\n      await page.click('.sidebar .tag-list .tag-pill:has-text(\"playwright-test\")');\n\n      // Should see the tag filter active\n      await expect(page.locator('.nav-link:has-text(\"playwright-test\")')).toBeVisible();\n\n      // Should show the article with that tag\n      await expect(page.locator(`h1:has-text(\"${article.title}\")`)).toBeVisible();\n    } else {\n      // If custom tag doesn't appear, use an existing popular tag from the demo backend\n      await page.click('.sidebar .tag-list .tag-pill:first-child');\n\n      // Get the tag text that was clicked\n      const tagText = await page.locator('.sidebar .tag-list .tag-pill:first-child').textContent();\n\n      // Should see the tag filter active\n      await expect(page.locator(`.nav-link:has-text(\"${tagText?.trim()}\")`)).toBeVisible();\n\n      // Should show articles with that tag\n      await expect(page.locator('.article-preview').first()).toBeVisible();\n    }\n  });\n\n  test('should switch between Global Feed and Your Feed', async ({ page }) => {\n    // Create user and article\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n\n    const article = generateUniqueArticle();\n    await createArticle(page, article);\n\n    // Go to home\n    await page.goto('/', { waitUntil: 'load' });\n\n    // Wait for articles to load\n    await page.waitForSelector('.article-preview', { timeout: 3000 });\n\n    // Should see our article and existing articles in Global Feed\n    await page.click('a:has-text(\"Global Feed\")');\n    // Wait for articles to load after clicking Global Feed\n    await page.waitForSelector('.article-preview', { timeout: 3000 });\n    await expect(page.locator(`h1:has-text(\"${article.title}\")`).first()).toBeVisible();\n\n    if (API_MODE) {\n      // Also should see johndoe's articles from demo backend\n      await expect(page.locator('.article-preview').first()).toBeVisible();\n    }\n\n    // Switch to Your Feed (should be empty since not following anyone)\n    await page.click('a:has-text(\"Your Feed\")');\n    // Should see empty state or own articles\n  });\n\n  test('should display popular tags', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n\n    // Create article with tags\n    const article = {\n      ...generateUniqueArticle(),\n      tags: ['popular', 'trending'],\n    };\n\n    await createArticle(page, article);\n\n    // Go to home\n    await page.goto('/');\n\n    // Should see tags in the sidebar\n    await expect(page.locator('.sidebar .tag-list')).toBeVisible();\n    await expect(page.locator('.sidebar .tag-list .tag-pill:has-text(\"popular\")')).toBeVisible();\n    await expect(page.locator('.sidebar .tag-list .tag-pill:has-text(\"trending\")')).toBeVisible();\n  });\n\n  test('should paginate articles', async ({ page, request }) => {\n    // Create user and 12 articles via API (much faster than UI)\n    const uniqueTag = `pag${Date.now()}`;\n    const user = generateUniqueUser();\n    const token = await registerUserViaAPI(request, user);\n    await createManyArticles(request, token, 12, uniqueTag);\n\n    // Login and navigate to our tag to see only our articles\n    await login(page, user.email, user.password);\n    await page.goto(`/tag/${uniqueTag}`);\n\n    await page.waitForSelector('.article-preview', { timeout: 3000 });\n\n    // Count articles on first page (should be 10 or less)\n    const firstPageCount = await page.locator('.article-preview').count();\n    expect(firstPageCount).toBeGreaterThan(0);\n    expect(firstPageCount).toBeLessThanOrEqual(10);\n  });\n\n  test('should navigate to article from author name', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n\n    const article = generateUniqueArticle();\n    await createArticle(page, article);\n\n    // Go to global feed to see the article we just created\n    await page.goto('/');\n\n    // Click on author name\n    await page.click(`.article-preview .author:has-text(\"${user.username}\")`);\n\n    // Should navigate to author profile\n    await expect(page).toHaveURL(`/profile/${user.username}`);\n  });\n\n  test('should show article count on profile tabs', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n\n    // Create articles (generate just-in-time so Date.now() is distinct)\n    const article1 = generateUniqueArticle();\n    await createArticle(page, article1);\n    const article2 = generateUniqueArticle();\n    await createArticle(page, article2);\n\n    // Favorite article1 - go to global feed to see the article\n    await page.goto('/');\n    await page.click(`.article-preview:has-text(\"${article1.title}\") button.btn-outline-primary`);\n\n    // Go to profile\n    await page.goto(`/profile/${user.username}`);\n\n    // Should have 2 articles in My Articles\n    await expect(page.locator('.article-preview')).toHaveCount(2);\n\n    // Click Favorited Articles tab (likely just says \"Favorited\")\n    await page.click('a:has-text(\"Favorited\")');\n\n    // Should have 1 favorited article\n    await expect(page.locator('.article-preview')).toHaveCount(1);\n  });\n\n  test('should handle empty states gracefully', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n\n    // Go to profile (no articles yet)\n    await page.goto(`/profile/${user.username}`, { waitUntil: 'load' });\n\n    // Wait for profile page to load\n    await page.waitForSelector('.user-info, h4', { timeout: 3000 });\n\n    // Check if there are article previews (there might be none on empty profile)\n    const articleCount = await page.locator('.article-preview').count();\n    // Empty profile should have 0 articles or show empty state message\n    expect(articleCount).toBeGreaterThanOrEqual(0);\n\n    // Check if Favorited tab exists and try to click it\n    const favoritedTabExists = (await page.locator('a:has-text(\"Favorited\")').count()) > 0;\n    if (favoritedTabExists) {\n      await page.click('a:has-text(\"Favorited\")');\n      // Should handle empty favorites gracefully (0 or more articles)\n      const favoritedCount = await page.locator('.article-preview').count();\n      expect(favoritedCount).toBeGreaterThanOrEqual(0);\n    }\n  });\n});\n"
  },
  {
    "path": "specs/e2e/null-fields.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { register, login, generateUniqueUser } from './helpers/auth';\nimport { registerUserViaAPI, updateUserViaAPI } from './helpers/api';\nimport { createArticle, generateUniqueArticle } from './helpers/articles';\nimport { addComment } from './helpers/comments';\n\n/**\n * Tests for null/empty image and bio field handling.\n * Verifies that a default avatar SVG is shown when image is null or empty,\n * and that bio fields never render the literal text \"null\".\n */\n\ntest.describe('Null/Empty Image and Bio Handling', () => {\n  // Brief cooldown between tests to avoid backend rate limiting\n  test.afterEach(async ({ context }) => {\n    await context.close();\n    await new Promise(resolve => setTimeout(resolve, 100));\n  });\n\n  test('newly registered user should show default avatar on profile page', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n    await page.goto(`/profile/${user.username}`, { waitUntil: 'load' });\n    await page.waitForSelector('.user-img');\n    const profileImg = page.locator('.user-img');\n    await expect(profileImg).toBeVisible();\n    const src = await profileImg.getAttribute('src');\n    expect(src).toContain('default-avatar.svg');\n  });\n\n  test('newly registered user should show default avatar in navbar', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n    const navImg = page.locator('nav .user-pic');\n    await expect(navImg).toBeVisible();\n    const src = await navImg.getAttribute('src');\n    expect(src).toContain('default-avatar.svg');\n  });\n\n  test('newly registered user should show default avatar on article meta', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n    const article = generateUniqueArticle();\n    await createArticle(page, article);\n    const articleMetaImg = page.locator('.article-meta img').first();\n    await expect(articleMetaImg).toBeVisible();\n    const src = await articleMetaImg.getAttribute('src');\n    expect(src).toContain('default-avatar.svg');\n  });\n\n  test('newly registered user should show default avatar in comment section', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n    const article = generateUniqueArticle();\n    await createArticle(page, article);\n    await addComment(page, 'Test comment for avatar check');\n    // Comment form author image\n    const commentFormImg = page.locator('.comment-form .comment-author-img');\n    await expect(commentFormImg).toBeVisible();\n    const formSrc = await commentFormImg.getAttribute('src');\n    expect(formSrc).toContain('default-avatar.svg');\n    // Posted comment author image\n    const commentImg = page.locator('.card:not(.comment-form) .comment-author-img').first();\n    await expect(commentImg).toBeVisible();\n    const commentSrc = await commentImg.getAttribute('src');\n    expect(commentSrc).toContain('default-avatar.svg');\n  });\n\n  test('setting image should display custom avatar on profile page', async ({ page, request }) => {\n    const user = generateUniqueUser();\n    const token = await registerUserViaAPI(request, user);\n    const testImage = 'https://api.realworld.io/images/smiley-cyrus.jpeg';\n    await updateUserViaAPI(request, token, { image: testImage });\n    await login(page, user.email, user.password);\n    await page.goto(`/profile/${user.username}`, { waitUntil: 'load' });\n    await page.waitForSelector('.user-img');\n    const profileImg = page.locator('.user-img');\n    await expect(profileImg).toHaveAttribute('src', testImage);\n  });\n\n  test('clearing image to empty string should restore default avatar', async ({ page, request }) => {\n    const user = generateUniqueUser();\n    const token = await registerUserViaAPI(request, user);\n    // Set then clear\n    await updateUserViaAPI(request, token, { image: 'https://api.realworld.io/images/smiley-cyrus.jpeg' });\n    await updateUserViaAPI(request, token, { image: '' });\n    await login(page, user.email, user.password);\n    await page.goto(`/profile/${user.username}`, { waitUntil: 'load' });\n    await page.waitForSelector('.user-img');\n    const profileImg = page.locator('.user-img');\n    const src = await profileImg.getAttribute('src');\n    expect(src).toContain('default-avatar.svg');\n  });\n\n  test('null bio should not render as literal \"null\" on profile page', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n    await page.goto(`/profile/${user.username}`, { waitUntil: 'load' });\n    await page.waitForSelector('.user-info');\n    const bioText = await page.locator('.user-info p').textContent();\n    expect(bioText?.trim()).not.toBe('null');\n    expect(bioText?.trim()).toBe('');\n  });\n\n  test('setting then clearing bio should not show stale data', async ({ page, request }) => {\n    // Cooldown: this test runs after many rapid API calls; backend needs breathing room\n    await new Promise(resolve => setTimeout(resolve, 1000));\n    const user = generateUniqueUser();\n    const token = await registerUserViaAPI(request, user);\n    const testBio = 'This is a test bio';\n    await updateUserViaAPI(request, token, { bio: testBio });\n    await updateUserViaAPI(request, token, { bio: '' });\n    await login(page, user.email, user.password);\n    await page.goto(`/profile/${user.username}`, { waitUntil: 'load' });\n    await page.waitForSelector('.user-info');\n    const bioText = await page.locator('.user-info p').textContent();\n    expect(bioText?.trim()).not.toBe(testBio);\n    expect(bioText?.trim()).not.toBe('null');\n  });\n\n  test('settings form should show empty string for null image', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n    await page.goto('/settings', { waitUntil: 'load' });\n    await expect(page.locator('input[name=\"image\"]')).toHaveValue('');\n  });\n\n  test('settings form should show empty string for null bio', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n    await page.goto('/settings', { waitUntil: 'load' });\n    await expect(page.locator('textarea[name=\"bio\"]')).toHaveValue('');\n  });\n\n  test('author avatars should render on other user articles in feed', async ({ page }) => {\n    // Cooldown: this test runs after many rapid API calls; backend needs breathing room\n    await new Promise(resolve => setTimeout(resolve, 1000));\n    // The global feed contains articles from the backend's seed users\n    await page.goto('/', { waitUntil: 'load' });\n    await page.locator('a.nav-link', { hasText: 'Global Feed' }).click();\n    // Wait for at least 2 article previews to load (seed data from multiple authors)\n    const previews = page.locator('.article-preview');\n    await expect(previews.nth(1)).toBeVisible({ timeout: 10000 });\n    const count = await previews.count();\n    const authors = new Set<string>();\n    for (let i = 0; i < count; i++) {\n      const preview = previews.nth(i);\n      const authorName = await preview.locator('.author').textContent();\n      if (authorName) authors.add(authorName.trim());\n      const img = preview.locator('.article-meta img');\n      await expect(img).toBeVisible();\n      await expect(img).toHaveAttribute('src', /\\.(svg|jpe?g|png|webp)(\\?.*)?$/i);\n      const loaded = await img.evaluate((el: HTMLImageElement) => el.naturalWidth > 0);\n      expect(loaded).toBe(true);\n    }\n    // Ensure the feed actually contains articles from different users\n    expect(authors.size).toBeGreaterThanOrEqual(2);\n  });\n});\n"
  },
  {
    "path": "specs/e2e/playwright.base.ts",
    "content": "import { type PlaywrightTestConfig, devices } from '@playwright/test';\n\n/**\n * Base Playwright configuration for RealWorld e2e tests.\n *\n * Implementations should extend this in their root playwright.config.ts:\n *\n * ```ts\n * import { defineConfig } from '@playwright/test';\n * import { baseConfig } from './e2e/playwright.base';\n *\n * export default defineConfig({\n *   ...baseConfig,\n *   use: { ...baseConfig.use, baseURL: 'http://localhost:3000' },\n *   webServer: {\n *     command: 'npm run start',\n *     url: 'http://localhost:3000',\n *     reuseExistingServer: !process.env.CI,\n *     timeout: 120_000,\n *   },\n * });\n * ```\n */\nexport const baseConfig: PlaywrightTestConfig = {\n  testDir: './e2e',\n  fullyParallel: false,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 1,\n  workers: 1,\n  reporter: 'html',\n\n  timeout: 15_000,\n\n  use: {\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n    actionTimeout: 5_000,\n    navigationTimeout: 10_000,\n  },\n\n  expect: {\n    timeout: 5_000,\n  },\n\n  projects: [\n    {\n      name: 'chromium',\n      use: { ...devices['Desktop Chrome'] },\n    },\n  ],\n};\n"
  },
  {
    "path": "specs/e2e/settings.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { register, generateUniqueUser } from './helpers/auth';\nimport { getToken, getCurrentUser } from './helpers/debug';\nimport { API_MODE } from './helpers/config';\n\n/**\n * Tests for the settings/profile update feature.\n * Each test updates a specific field and verifies the result.\n *\n * API mode: form submits via PUT /api/user, verifies API response.\n * Fullstack mode: form submits via POST, verifies via rendered profile page.\n */\n\ntest.describe('Settings - Profile Updates', () => {\n  test.afterEach(async ({ context }) => {\n    await context.close();\n    await new Promise(resolve => setTimeout(resolve, 500));\n  });\n\n  test('should update bio only', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n\n    // Go to settings\n    await page.goto('/settings');\n    await expect(page.locator('input[name=\"username\"]')).toHaveValue(user.username);\n\n    // Update bio\n    const newBio = `Bio updated at ${Date.now()}`;\n    await page.fill('textarea[name=\"bio\"]', newBio);\n\n    if (API_MODE) {\n      // Submit and wait for API response\n      const responsePromise = page.waitForResponse(\n        res => res.url().includes('/user') && res.request().method() === 'PUT',\n      );\n      await page.click('button[type=\"submit\"]');\n      const response = await responsePromise;\n\n      // Verify API response\n      expect(response.status()).toBe(200);\n      const responseBody = await response.json();\n      expect(responseBody.user.bio).toBe(newBio);\n      expect(responseBody.user.username).toBe(user.username);\n      expect(responseBody.user.email).toBe(user.email);\n\n      // Verify navigation happened\n      await expect(page).toHaveURL(new RegExp(`/profile/${user.username}`));\n\n      // Verify token is still present (auth not corrupted)\n      const token = await getToken(page);\n      expect(token).not.toBeNull();\n\n      // Verify current user is updated\n      const currentUser = await getCurrentUser(page);\n      expect(currentUser).not.toBeNull();\n      expect(currentUser?.username).toBe(user.username);\n      expect(currentUser?.bio).toBe(newBio);\n    } else {\n      // Submit form and wait for redirect to profile\n      await Promise.all([page.waitForURL(/\\/profile\\//), page.click('button[type=\"submit\"]')]);\n\n      await expect(page).toHaveURL(new RegExp(`/profile/${user.username}`));\n      await expect(page.locator('.user-info')).toContainText(newBio);\n    }\n  });\n\n  test('should update image only', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n\n    await page.goto('/settings');\n    await expect(page.locator('input[name=\"username\"]')).toHaveValue(user.username);\n\n    // Update image\n    const newImage = 'https://api.realworld.io/images/smiley-cyrus.jpeg';\n    await page.fill('input[name=\"image\"]', newImage);\n\n    if (API_MODE) {\n      const responsePromise = page.waitForResponse(\n        res => res.url().includes('/user') && res.request().method() === 'PUT',\n      );\n      await page.click('button[type=\"submit\"]');\n      const response = await responsePromise;\n\n      expect(response.status()).toBe(200);\n      const responseBody = await response.json();\n      expect(responseBody.user.image).toBe(newImage);\n      expect(responseBody.user.username).toBe(user.username);\n\n      await expect(page).toHaveURL(new RegExp(`/profile/${user.username}`));\n\n      const token = await getToken(page);\n      expect(token).not.toBeNull();\n\n      const currentUser = await getCurrentUser(page);\n      expect(currentUser).not.toBeNull();\n      expect(currentUser?.image).toBe(newImage);\n    } else {\n      await Promise.all([page.waitForURL(/\\/profile\\//), page.click('button[type=\"submit\"]')]);\n\n      await expect(page).toHaveURL(new RegExp(`/profile/${user.username}`));\n      await expect(page.locator(`.user-img[src=\"${newImage}\"]`)).toBeVisible();\n    }\n  });\n\n  test('should update bio and image together', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n\n    await page.goto('/settings');\n\n    // Update both fields\n    const newBio = `Multi-update bio ${Date.now()}`;\n    const newImage = 'https://api.realworld.io/images/smiley-cyrus.jpeg';\n    await page.fill('textarea[name=\"bio\"]', newBio);\n    await page.fill('input[name=\"image\"]', newImage);\n\n    if (API_MODE) {\n      const responsePromise = page.waitForResponse(\n        res => res.url().includes('/user') && res.request().method() === 'PUT',\n      );\n      await page.click('button[type=\"submit\"]');\n      const response = await responsePromise;\n\n      expect(response.status()).toBe(200);\n      const responseBody = await response.json();\n      expect(responseBody.user.bio).toBe(newBio);\n      expect(responseBody.user.image).toBe(newImage);\n      expect(responseBody.user.username).toBe(user.username);\n      expect(responseBody.user.email).toBe(user.email);\n\n      await expect(page).toHaveURL(new RegExp(`/profile/${user.username}`));\n\n      const token = await getToken(page);\n      expect(token).not.toBeNull();\n\n      const currentUser = await getCurrentUser(page);\n      expect(currentUser?.bio).toBe(newBio);\n      expect(currentUser?.image).toBe(newImage);\n    } else {\n      await Promise.all([page.waitForURL(/\\/profile\\//), page.click('button[type=\"submit\"]')]);\n\n      await expect(page).toHaveURL(new RegExp(`/profile/${user.username}`));\n      await expect(page.locator('.user-info')).toContainText(newBio);\n      await expect(page.locator(`.user-img[src=\"${newImage}\"]`)).toBeVisible();\n    }\n  });\n\n  test('should display updated bio on profile page', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n\n    await page.goto('/settings');\n\n    const newBio = `Visible bio ${Date.now()}`;\n    await page.fill('textarea[name=\"bio\"]', newBio);\n\n    if (API_MODE) {\n      await Promise.all([\n        page.waitForResponse(res => res.url().includes('/user') && res.request().method() === 'PUT'),\n        page.click('button[type=\"submit\"]'),\n      ]);\n    } else {\n      await Promise.all([page.waitForURL(/\\/profile\\//), page.click('button[type=\"submit\"]')]);\n    }\n\n    // Wait for profile page\n    await expect(page).toHaveURL(new RegExp(`/profile/${user.username}`));\n\n    // Verify bio is displayed on profile\n    await expect(page.locator('.user-info')).toContainText(newBio);\n  });\n\n  test('should display updated image on profile page', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n\n    await page.goto('/settings');\n\n    const newImage = 'https://api.realworld.io/images/smiley-cyrus.jpeg';\n    await page.fill('input[name=\"image\"]', newImage);\n\n    if (API_MODE) {\n      await Promise.all([\n        page.waitForResponse(res => res.url().includes('/user') && res.request().method() === 'PUT'),\n        page.click('button[type=\"submit\"]'),\n      ]);\n    } else {\n      await Promise.all([page.waitForURL(/\\/profile\\//), page.click('button[type=\"submit\"]')]);\n    }\n\n    // Wait for profile page\n    await expect(page).toHaveURL(new RegExp(`/profile/${user.username}`));\n\n    // Verify image is displayed on profile (use .user-img to avoid matching navbar)\n    await expect(page.locator(`.user-img[src=\"${newImage}\"]`)).toBeVisible();\n  });\n\n  test('should preserve username in navbar after update', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n\n    // Verify navbar shows username before update\n    await expect(page.locator(`nav a[href=\"/profile/${user.username}\"]`)).toBeVisible();\n\n    await page.goto('/settings');\n\n    const newBio = `Navbar test ${Date.now()}`;\n    await page.fill('textarea[name=\"bio\"]', newBio);\n\n    if (API_MODE) {\n      await Promise.all([\n        page.waitForResponse(res => res.url().includes('/user') && res.request().method() === 'PUT'),\n        page.click('button[type=\"submit\"]'),\n      ]);\n    } else {\n      await Promise.all([page.waitForURL(/\\/profile\\//), page.click('button[type=\"submit\"]')]);\n    }\n\n    // Verify navbar STILL shows username after update (not corrupted)\n    await expect(page.locator(`nav a[href=\"/profile/${user.username}\"]`)).toBeVisible();\n  });\n\n  test('should allow navigation to settings again after update', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n\n    await page.goto('/settings');\n\n    const bio1 = `First update ${Date.now()}`;\n    await page.fill('textarea[name=\"bio\"]', bio1);\n\n    if (API_MODE) {\n      await Promise.all([\n        page.waitForResponse(res => res.url().includes('/user') && res.request().method() === 'PUT'),\n        page.click('button[type=\"submit\"]'),\n      ]);\n    } else {\n      await Promise.all([page.waitForURL(/\\/profile\\//), page.click('button[type=\"submit\"]')]);\n    }\n\n    await expect(page).toHaveURL(new RegExp(`/profile/${user.username}`));\n\n    // Go back to settings\n    await page.goto('/settings');\n    await expect(page.locator('input[name=\"username\"]')).toHaveValue(user.username);\n\n    // Verify previous update persisted\n    await expect(page.locator('textarea[name=\"bio\"]')).toHaveValue(bio1);\n\n    // Make another update\n    const bio2 = `Second update ${Date.now()}`;\n    await page.fill('textarea[name=\"bio\"]', bio2);\n\n    if (API_MODE) {\n      await Promise.all([\n        page.waitForResponse(res => res.url().includes('/user') && res.request().method() === 'PUT'),\n        page.click('button[type=\"submit\"]'),\n      ]);\n\n      const currentUser = await getCurrentUser(page);\n      expect(currentUser?.bio).toBe(bio2);\n    } else {\n      await Promise.all([page.waitForURL(/\\/profile\\//), page.click('button[type=\"submit\"]')]);\n\n      await expect(page).toHaveURL(new RegExp(`/profile/${user.username}`));\n      await expect(page.locator('.user-info')).toContainText(bio2);\n    }\n  });\n});\n"
  },
  {
    "path": "specs/e2e/social.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { register, generateUniqueUser } from './helpers/auth';\nimport { createArticle, generateUniqueArticle } from './helpers/articles';\nimport { followUser, unfollowUser } from './helpers/profile';\nimport { registerUserViaAPI, createArticleViaAPI } from './helpers/api';\nimport { API_MODE } from './helpers/config';\n\ntest.describe('Social Features', () => {\n  test.afterEach(async ({ context }) => {\n    // Close the browser context to ensure complete isolation between tests.\n    // This releases browser instances, network connections, and other resources.\n    await context.close();\n    // Wait 500ms to allow async cleanup operations to complete.\n    // Without this delay, running 6+ tests in sequence causes flaky failures\n    // due to resource exhaustion (network connections, file descriptors, etc).\n    // This timing issue manifests as timeouts when loading article pages.\n    // This will be investigated and fixed later.\n    await new Promise(resolve => setTimeout(resolve, 500));\n  });\n\n  test('should follow and unfollow a user', async ({ page, request }) => {\n    // Register our test user\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n\n    // API mode: use johndoe (demo backend, user isolation prevents creating visible users)\n    // Fullstack mode: create a second user via API on the same local DB\n    let targetUsername: string;\n    if (API_MODE) {\n      targetUsername = 'johndoe';\n    } else {\n      const otherUser = generateUniqueUser();\n      await registerUserViaAPI(request, otherUser);\n      targetUsername = otherUser.username;\n    }\n\n    await followUser(page, targetUsername);\n    await expect(page.locator('button:has-text(\"Unfollow\")')).toBeVisible();\n\n    await unfollowUser(page, targetUsername);\n    await expect(page.locator('button:has-text(\"Follow\")')).toBeVisible();\n  });\n\n  test('should view own profile', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n\n    // Click on profile link\n    await page.click(`a[href=\"/profile/${user.username}\"]`);\n\n    // Should show user information\n    await expect(page.locator('h4')).toHaveText(user.username);\n\n    // Should see Edit Profile Settings button (own profile)\n    await expect(page.locator('a[href=\"/settings\"]').filter({ hasText: 'Edit Profile Settings' })).toBeVisible();\n\n    // Should not see Follow button (can't follow yourself)\n    await expect(page.locator('button:has-text(\"Follow\")')).not.toBeVisible();\n  });\n\n  test('should view other user profile', async ({ page, request }) => {\n    // Register our test user\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n\n    // API mode: johndoe exists with articles on the demo backend\n    // Fullstack mode: create a second user with an article\n    let targetUsername: string;\n    if (API_MODE) {\n      targetUsername = 'johndoe';\n    } else {\n      const otherUser = generateUniqueUser();\n      const otherToken = await registerUserViaAPI(request, otherUser);\n      await createArticleViaAPI(request, otherToken, {\n        title: `Article by ${otherUser.username}`,\n        description: 'A test article',\n        body: 'Body content',\n      });\n      targetUsername = otherUser.username;\n    }\n\n    await page.goto(`/profile/${targetUsername}`, { waitUntil: 'load' });\n    await page.waitForSelector('h4', { timeout: 10000 });\n\n    await expect(page.locator('h4')).toHaveText(targetUsername);\n    await expect(page.locator('button:has-text(\"Follow\")')).toBeVisible();\n    await expect(\n      page.locator('.user-info a[href=\"/settings\"]').filter({ hasText: 'Edit Profile Settings' }),\n    ).not.toBeVisible();\n    await expect(page.locator('.article-preview').first()).toBeVisible();\n  });\n\n  test('should display user articles on profile', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n\n    // Create multiple articles (generate just-in-time so Date.now() is distinct)\n    const article1 = generateUniqueArticle();\n    await createArticle(page, article1);\n    const article2 = generateUniqueArticle();\n    await createArticle(page, article2);\n\n    // Go to profile\n    await page.goto(`/profile/${user.username}`);\n\n    // Both articles should be visible (use .first() to avoid strict mode violation)\n    await expect(page.locator(`h1:has-text(\"${article1.title}\")`).first()).toBeVisible();\n    await expect(page.locator(`h1:has-text(\"${article2.title}\")`).first()).toBeVisible();\n  });\n\n  test('should display favorited articles on profile', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n\n    // Go to global feed to find an existing article (can't favorite own articles)\n    await page.goto('/', { waitUntil: 'load' });\n\n    // Wait for articles to load\n    await page.waitForSelector('.article-preview', { timeout: 10000 });\n\n    // Get the title of the first article in the feed\n    const firstArticleTitle = await page.locator('.article-preview h1').first().textContent();\n\n    // Click on first article to go to its detail page\n    await page.click('.article-preview h1:first-child');\n    await page.waitForURL(/\\/article\\/.+/, { timeout: 10000 });\n\n    // Wait for article page to load\n    await page.waitForSelector('button:has-text(\"Favorite\"), button:has-text(\"Unfavorite\")', { timeout: 10000 });\n\n    // Check if already favorited, if not favorite it\n    const isFavorited = (await page.locator('button:has-text(\"Unfavorite\")').count()) > 0;\n    if (!isFavorited) {\n      await page.click('button.btn-outline-primary:has-text(\"Favorite\")');\n      // Wait for the favorite to complete\n      await page.waitForSelector('button.btn-primary:has-text(\"Unfavorite\")', { timeout: 10000 });\n    }\n\n    // Go to profile and click Favorited tab\n    await page.goto(`/profile/${user.username}`, { waitUntil: 'load' });\n    await page.waitForSelector('a:has-text(\"Favorited\")', { timeout: 3000 });\n    await page.click('a:has-text(\"Favorited\")');\n\n    // Wait for URL to change then for articles to load\n    await expect(page).toHaveURL(`/profile/${user.username}/favorites`);\n    await expect(page.locator('.article-preview').first()).toBeVisible({ timeout: 3000 });\n  });\n\n  test('should display followed users articles in feed', async ({ page, request }) => {\n    // Register our test user\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n\n    // API mode: johndoe exists with articles on the demo backend\n    // Fullstack mode: create a second user with an article\n    let targetUsername: string;\n    if (API_MODE) {\n      targetUsername = 'johndoe';\n    } else {\n      const otherUser = generateUniqueUser();\n      const otherToken = await registerUserViaAPI(request, otherUser);\n      await createArticleViaAPI(request, otherToken, {\n        title: `Feed article by ${otherUser.username}`,\n        description: 'Should appear in feed',\n        body: 'Body content',\n      });\n      targetUsername = otherUser.username;\n    }\n    await followUser(page, targetUsername);\n\n    // Go to home and click \"Your Feed\"\n    await page.goto('/', { waitUntil: 'load' });\n    await page.waitForSelector('.feed-toggle', { timeout: 10000 });\n    await page.click('a:has-text(\"Your Feed\")');\n\n    // Wait for articles to load\n    await page.waitForSelector('.article-preview', { timeout: 10000 });\n    await expect(page.locator('.article-preview').first()).toBeVisible();\n  });\n});\n"
  },
  {
    "path": "specs/e2e/url-navigation.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { register, generateUniqueUser, login } from './helpers/auth';\nimport { registerUserViaAPI, createManyArticles } from './helpers/api';\n\ntest.describe('URL-based Navigation (Realworld Issue #691)', () => {\n  test.afterEach(async ({ context }) => {\n    await context.close();\n    await new Promise(resolve => setTimeout(resolve, 1000));\n  });\n\n  test('/ should show Global Feed for everyone', async ({ page }) => {\n    await page.goto('/');\n    // Should see Global Feed active\n    await expect(page.locator('.nav-link:has-text(\"Global Feed\")')).toHaveClass(/active/);\n    // Should see articles\n    await expect(page.locator('.article-preview').first()).toBeVisible({ timeout: 2000 });\n    // URL should be /\n    await expect(page).toHaveURL('/');\n  });\n\n  test('/?feed=following should show Your Feed (authenticated)', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n    await page.goto('/?feed=following');\n    // Should see Your Feed active\n    await expect(page.locator('.nav-link:has-text(\"Your Feed\")')).toHaveClass(/active/);\n    // URL should have feed param\n    await expect(page).toHaveURL('/?feed=following');\n  });\n\n  test('/?feed=following should redirect to /login when not authenticated', async ({ page }) => {\n    await page.goto('/?feed=following');\n    // Should be redirected to login\n    await expect(page).toHaveURL('/login');\n  });\n\n  test('/tag/:tag should filter by tag', async ({ page }) => {\n    await page.goto('/');\n    await page.waitForSelector('.sidebar .tag-list', { timeout: 2000 });\n    // Get a tag from the sidebar\n    const firstTag = await page.locator('.sidebar .tag-list .tag-pill').first().textContent();\n    expect(firstTag).toBeTruthy();\n    // Navigate directly to the tag URL\n    await page.goto(`/tag/${firstTag?.trim()}`);\n    // Should see the tag filter active\n    await expect(page.locator(`.nav-link:has-text(\"${firstTag?.trim()}\")`)).toBeVisible();\n    await expect(page.locator(`.nav-link:has-text(\"${firstTag?.trim()}\")`)).toHaveClass(/active/);\n  });\n\n  test('tabs should have correct href attributes', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n    await page.goto('/');\n    await page.waitForSelector('.feed-toggle', { timeout: 2000 });\n    // Your Feed should link to /?feed=following\n    const yourFeedLink = page.locator('.nav-link:has-text(\"Your Feed\")');\n    await expect(yourFeedLink).toHaveAttribute('href', '/?feed=following');\n    // Global Feed should link to /\n    const globalFeedLink = page.locator('.nav-link:has-text(\"Global Feed\")');\n    await expect(globalFeedLink).toHaveAttribute('href', '/');\n  });\n\n  test('clicking Your Feed should navigate to /?feed=following', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n    // Should be at /\n    await expect(page).toHaveURL('/');\n    // Click Your Feed\n    await page.click('.nav-link:has-text(\"Your Feed\")');\n    // Should navigate to /?feed=following\n    await expect(page).toHaveURL('/?feed=following');\n    await expect(page.locator('.nav-link:has-text(\"Your Feed\")')).toHaveClass(/active/);\n  });\n\n  test('clicking Global Feed should navigate to /', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n    // Go to Your Feed first\n    await page.goto('/?feed=following');\n    await expect(page.locator('.nav-link:has-text(\"Your Feed\")')).toHaveClass(/active/);\n    // Click Global Feed\n    await page.click('.nav-link:has-text(\"Global Feed\")');\n    // Should navigate to /\n    await expect(page).toHaveURL('/');\n    await expect(page.locator('.nav-link:has-text(\"Global Feed\")')).toHaveClass(/active/);\n  });\n\n  test('empty Your Feed shows helpful message with link to Global Feed', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n    await page.goto('/?feed=following');\n    // Wait for loading to complete\n    await page.waitForSelector('.empty-feed-message', { timeout: 2000 });\n    // Should show helpful empty message\n    const emptyMessage = page.locator('.empty-feed-message');\n    await expect(emptyMessage).toContainText('Your feed is empty');\n    // Should have a link to Global Feed\n    const globalFeedLink = emptyMessage.locator('a[href=\"/\"]');\n    await expect(globalFeedLink).toBeVisible();\n  });\n});\n\ntest.describe('Pagination', () => {\n  test.afterEach(async ({ context }) => {\n    await context.close();\n    await new Promise(resolve => setTimeout(resolve, 1000));\n  });\n\n  test('pagination should update URL with ?page=N', async ({ page, request }) => {\n    // Create user and 15 articles with a unique tag for this test\n    const uniqueTag = `pag${Date.now()}`;\n    const testUser = generateUniqueUser();\n    const token = await registerUserViaAPI(request, testUser);\n    await createManyArticles(request, token, 15, uniqueTag);\n    // Login as the user who created the articles (session isolation)\n    await login(page, testUser.email, testUser.password);\n    // Navigate to the tag page - this shows ONLY our articles\n    await page.goto(`/tag/${uniqueTag}`);\n    await page.waitForSelector('.article-preview', { timeout: 2000 });\n    // Should have pagination (15 articles = 2 pages with limit 10)\n    await expect(page.locator('.pagination button:has-text(\"2\")')).toBeVisible({ timeout: 2000 });\n    // Click page 2\n    await page.click('.pagination button:has-text(\"2\")');\n    // URL should have ?page=2\n    await expect(page).toHaveURL(new RegExp(`/tag/${uniqueTag}\\\\?page=2`));\n    // Page 2 should be active\n    await expect(page.locator('.pagination .page-item:has(button:has-text(\"2\"))')).toHaveClass(/active/);\n  });\n\n  test('should load correct page when navigating directly to ?page=N', async ({ page, request }) => {\n    // Create user and 15 articles with a unique tag for this test\n    const uniqueTag = `pag${Date.now()}`;\n    const testUser = generateUniqueUser();\n    const token = await registerUserViaAPI(request, testUser);\n    await createManyArticles(request, token, 15, uniqueTag);\n    // Login as the user who created the articles (session isolation)\n    await login(page, testUser.email, testUser.password);\n    // Go directly to page 2 of the tag\n    await page.goto(`/tag/${uniqueTag}?page=2`);\n    await page.waitForSelector('.article-preview', { timeout: 2000 });\n    // Page 2 should be active\n    await expect(page.locator('.pagination .page-item:has(button:has-text(\"2\"))')).toHaveClass(/active/);\n    // URL should have page=2\n    const url = new URL(page.url());\n    expect(url.searchParams.get('page')).toBe('2');\n  });\n\n  test('pagination URL preserves feed=following parameter', async ({ page }) => {\n    const user = generateUniqueUser();\n    await register(page, user.username, user.email, user.password);\n    // Your Feed shows articles from users you FOLLOW (not your own articles)\n    // Just verify that if pagination exists on Your Feed, the URL is correct\n    await page.goto('/?feed=following');\n    // Wait for the page to load (might be empty or have articles)\n    await page.waitForSelector('.article-preview, .empty-feed-message', { timeout: 2000 });\n    // Check if pagination exists (depends on followed users having 11+ articles)\n    const page2Button = page.locator('.pagination button:has-text(\"2\")');\n    const hasPage2 = await page2Button.isVisible().catch(() => false);\n    if (hasPage2) {\n      // Click page 2\n      await page.click('.pagination button:has-text(\"2\")');\n      // URL should preserve feed param and add page\n      await expect(page).toHaveURL('/?feed=following&page=2');\n    } else {\n      // No pagination available - just verify URL structure is correct\n      await expect(page).toHaveURL('/?feed=following');\n    }\n  });\n\n  test('pagination should work with /tag/:tag', async ({ page, request }) => {\n    // Create user and 15 articles with a unique tag for this test\n    const uniqueTag = `pag${Date.now()}`;\n    const testUser = generateUniqueUser();\n    const token = await registerUserViaAPI(request, testUser);\n    await createManyArticles(request, token, 15, uniqueTag);\n    // Login as the user who created the articles (session isolation)\n    await login(page, testUser.email, testUser.password);\n    // Navigate to our tag\n    await page.goto(`/tag/${uniqueTag}`);\n    await page.waitForSelector('.article-preview', { timeout: 2000 });\n    // Should have pagination\n    await expect(page.locator('.pagination button:has-text(\"2\")')).toBeVisible({ timeout: 2000 });\n    // Click page 2\n    await page.click('.pagination button:has-text(\"2\")');\n    // Wait for URL to update after page navigation\n    await expect(page).toHaveURL(`/tag/${uniqueTag}?page=2`);\n  });\n\n  test('page should reset when switching feeds', async ({ page, request }) => {\n    // Create user and 15 articles with a unique tag for this test\n    const uniqueTag = `pag${Date.now()}`;\n    const testUser = generateUniqueUser();\n    const token = await registerUserViaAPI(request, testUser);\n    await createManyArticles(request, token, 15, uniqueTag);\n    // Login as the user who created the articles (session isolation)\n    await login(page, testUser.email, testUser.password);\n    // Navigate to our tag\n    await page.goto(`/tag/${uniqueTag}`);\n    await page.waitForSelector('.article-preview', { timeout: 2000 });\n    // Should have pagination\n    await expect(page.locator('.pagination button:has-text(\"2\")')).toBeVisible({ timeout: 2000 });\n    // Go to page 2\n    await page.click('.pagination button:has-text(\"2\")');\n    await expect(page).toHaveURL(new RegExp(`/tag/${uniqueTag}\\\\?page=2`));\n    // Click Global Feed and wait for URL to change to root path\n    await page.click('.nav-link:has-text(\"Global Feed\")');\n    await expect(page).toHaveURL('/');\n    // Verify articles loaded\n    await page.waitForSelector('.article-preview', { timeout: 2000 });\n  });\n\n  test('tag pagination shows correct articles per page', async ({ page, request }) => {\n    // Create user and 15 articles with a unique tag for this test\n    const uniqueTag = `pag${Date.now()}`;\n    const testUser = generateUniqueUser();\n    const token = await registerUserViaAPI(request, testUser);\n    await createManyArticles(request, token, 15, uniqueTag);\n    // Login as the user who created the articles (session isolation)\n    await login(page, testUser.email, testUser.password);\n    // Navigate to our tag\n    await page.goto(`/tag/${uniqueTag}`);\n    await page.waitForSelector('.article-preview', { timeout: 2000 });\n    // Should have pagination (15 articles = 2 pages)\n    await expect(page.locator('.pagination button:has-text(\"2\")')).toBeVisible({ timeout: 2000 });\n    // First page should show 10 articles\n    const articlesOnPage1 = await page.locator('.article-preview').count();\n    expect(articlesOnPage1).toBe(10);\n    // Click page 2\n    await page.click('.pagination button:has-text(\"2\")');\n    // Wait for page 2 to be active (Angular routing/rendering delay)\n    await expect(page.locator('.pagination .page-item:has(button:has-text(\"2\"))')).toHaveClass(/active/, {\n      timeout: 2000,\n    });\n    await page.waitForSelector('.article-preview', { timeout: 2000 });\n    // URL should show ?page=2\n    await expect(page).toHaveURL(new RegExp(`/tag/${uniqueTag}\\\\?page=2`));\n    // Small wait for Angular to finish rendering the new page\n    await page.waitForTimeout(500);\n    // Second page should have 5 articles (15 - 10 = 5)\n    const articlesOnPage2 = await page.locator('.article-preview').count();\n    expect(articlesOnPage2).toBe(5);\n  });\n});\n"
  },
  {
    "path": "specs/e2e/user-fetch-errors.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { getToken, getAuthState } from './helpers/debug';\nimport { API_MODE } from './helpers/config';\n\ntest.beforeEach(({ }, testInfo) => {\n  testInfo.skip(!API_MODE, 'API-only: all tests use page.route() + localStorage');\n});\n\nconst API_BASE = 'https://api.realworld.show/api';\n\n/**\n * Tests for error handling when the app tries to fetch the current user on initialization.\n * The app should gracefully handle all error scenarios without crashing or showing a blank screen.\n *\n * Behavior:\n * - 4XX errors: logout (clear token, show unauthenticated UI)\n * - 5XX/network errors: \"unavailable\" mode (keep token, show reconnect option)\n */\n\ntest.describe('User Fetch Errors on App Initialization - 4XX (should logout)', () => {\n  test('should handle 400 Bad Request on /api/user', async ({ page }) => {\n    // Set up route mock BEFORE navigating\n    await page.route(`${API_BASE}/user`, route => {\n      route.fulfill({\n        status: 400,\n        contentType: 'application/json',\n        body: JSON.stringify({ errors: { request: ['is malformed'] } }),\n      });\n    });\n    // Navigate and set token\n    await page.goto('/');\n    await page.evaluate(() => {\n      localStorage.setItem('jwtToken', 'some-token-that-will-be-tested');\n    });\n    await page.reload();\n    // App should not crash - should show logged out state\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    await expect(page.locator('a[href=\"/login\"]')).toBeVisible();\n    await expect(page.locator('a[href=\"/register\"]')).toBeVisible();\n    // Invalid token should be cleared\n    const token = await getToken(page);\n    expect(token).toBeNull();\n  });\n\n  test('should handle 401 Unauthorized on /api/user', async ({ page }) => {\n    await page.route(`${API_BASE}/user`, route => {\n      route.fulfill({\n        status: 401,\n        contentType: 'application/json',\n        body: JSON.stringify({ errors: { message: ['Token is invalid or expired'] } }),\n      });\n    });\n    await page.goto('/');\n    await page.evaluate(() => {\n      localStorage.setItem('jwtToken', 'some-token-that-will-be-tested');\n    });\n    await page.reload();\n    // App should not crash - should show logged out state\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    await expect(page.locator('a[href=\"/login\"]')).toBeVisible();\n    await expect(page.locator('a[href=\"/register\"]')).toBeVisible();\n    // 401 on /user shouldn't break unrelated features (articles should still load)\n    await expect(page.locator('.article-preview').first()).toBeVisible();\n    // Invalid token should be cleared (use debug interface)\n    const token = await getToken(page);\n    expect(token).toBeNull();\n    const authState = await getAuthState(page);\n    expect(authState).toBe('unauthenticated');\n  });\n\n  test('should handle 403 Forbidden on /api/user', async ({ page }) => {\n    await page.route(`${API_BASE}/user`, route => {\n      route.fulfill({\n        status: 403,\n        contentType: 'application/json',\n        body: JSON.stringify({ errors: { message: ['Access forbidden'] } }),\n      });\n    });\n    await page.goto('/');\n    await page.evaluate(() => {\n      localStorage.setItem('jwtToken', 'some-token-that-will-be-tested');\n    });\n    await page.reload();\n    // App should not crash - should show logged out state\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    await expect(page.locator('a[href=\"/login\"]')).toBeVisible();\n    await expect(page.locator('a[href=\"/register\"]')).toBeVisible();\n    // Token should be cleared on 403\n    const token = await getToken(page);\n    expect(token).toBeNull();\n  });\n\n  test('should handle 404 Not Found on /api/user', async ({ page }) => {\n    await page.route(`${API_BASE}/user`, route => {\n      route.fulfill({\n        status: 404,\n        contentType: 'application/json',\n        body: JSON.stringify({ errors: { user: ['not found'] } }),\n      });\n    });\n    await page.goto('/');\n    await page.evaluate(() => {\n      localStorage.setItem('jwtToken', 'some-token-that-will-be-tested');\n    });\n    await page.reload();\n    // App should not crash - should show logged out state\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    await expect(page.locator('a[href=\"/login\"]')).toBeVisible();\n    await expect(page.locator('a[href=\"/register\"]')).toBeVisible();\n    // Token should be cleared on 404\n    const token = await getToken(page);\n    expect(token).toBeNull();\n  });\n});\n\ntest.describe('User Fetch Errors on App Initialization - 5XX (should enter unavailable mode)', () => {\n  test('should handle 500 Internal Server Error on /api/user', async ({ page }) => {\n    await page.route(`${API_BASE}/user`, route => {\n      route.fulfill({\n        status: 500,\n        contentType: 'application/json',\n        body: JSON.stringify({ errors: { server: ['Internal server error'] } }),\n      });\n    });\n    await page.goto('/');\n    await page.evaluate(() => {\n      localStorage.setItem('jwtToken', 'some-token-that-will-be-tested');\n    });\n    await page.reload();\n    // App should not crash - should show \"unavailable\" mode with reconnect option\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    await expect(page.locator('text=Connecting')).toBeVisible();\n    // Token should be KEPT (server error, not auth error)\n    const token = await getToken(page);\n    expect(token).not.toBeNull();\n  });\n\n  test('should allow browsing app in unavailable mode and persist token across reloads', async ({ page }) => {\n    // Mock /user to always return 500\n    await page.route(`${API_BASE}/user`, route => {\n      route.fulfill({\n        status: 500,\n        contentType: 'application/json',\n        body: JSON.stringify({ errors: { server: ['Internal server error'] } }),\n      });\n    });\n    // Set token and trigger unavailable mode\n    await page.goto('/');\n    await page.evaluate(() => {\n      localStorage.setItem('jwtToken', 'my-precious-token');\n    });\n    await page.reload();\n    // Verify we're in unavailable mode\n    await expect(page.locator('text=Connecting')).toBeVisible();\n    const tokenBefore = await getToken(page);\n    expect(tokenBefore).toBe('my-precious-token');\n    // Browse the app - click on an article (Global Feed should be visible)\n    await expect(page.locator('text=Global Feed')).toBeVisible();\n    // Click on a tag to filter articles (tests navigation within the app)\n    const firstTag = page.locator('.tag-pill').first();\n    if (await firstTag.isVisible()) {\n      await firstTag.click();\n      await expect(page.locator('nav.navbar')).toBeVisible();\n    }\n    // Navigate to home via navbar\n    await page.click('a.nav-link:has-text(\"Home\")');\n    await expect(page.locator('text=Global Feed')).toBeVisible();\n    // Manually reload the page (simulating user pressing F5)\n    await page.reload();\n    // Token should STILL be there after manual reload\n    const tokenAfterReload = await getToken(page);\n    expect(tokenAfterReload).toBe('my-precious-token');\n    // Should still be in unavailable mode (server still returning 500)\n    await expect(page.locator('text=Connecting')).toBeVisible();\n    // App should still be functional\n    await expect(page.locator('text=Global Feed')).toBeVisible();\n  });\n\n  test('should handle network timeout on /api/user', async ({ page }) => {\n    await page.route(`${API_BASE}/user`, route => {\n      route.abort('timedout');\n    });\n    await page.goto('/');\n    await page.evaluate(() => {\n      localStorage.setItem('jwtToken', 'some-token-that-will-be-tested');\n    });\n    await page.reload();\n    // App should not crash - should show \"unavailable\" mode\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    await expect(page.locator('text=Connecting')).toBeVisible();\n    // Token should be KEPT (network error, not auth error)\n    const token = await getToken(page);\n    expect(token).not.toBeNull();\n  });\n\n  test('should handle network failure on /api/user', async ({ page }) => {\n    await page.route(`${API_BASE}/user`, route => {\n      route.abort('connectionrefused');\n    });\n    await page.goto('/');\n    await page.evaluate(() => {\n      localStorage.setItem('jwtToken', 'some-token-that-will-be-tested');\n    });\n    await page.reload();\n    // App should not crash - should show \"unavailable\" mode\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    await expect(page.locator('text=Connecting')).toBeVisible();\n    // Token should be KEPT (network error, not auth error)\n    const token = await getToken(page);\n    expect(token).not.toBeNull();\n  });\n\n  test('should handle empty response on /api/user', async ({ page }) => {\n    await page.route(`${API_BASE}/user`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: '',\n      });\n    });\n    await page.goto('/');\n    await page.evaluate(() => {\n      localStorage.setItem('jwtToken', 'some-token-that-will-be-tested');\n    });\n    await page.reload();\n    // App should not crash (empty 200 is a server bug, app may stay in loading state)\n    await expect(page.locator('nav.navbar')).toBeVisible();\n  });\n\n  test('should handle malformed JSON on /api/user', async ({ page }) => {\n    await page.route(`${API_BASE}/user`, route => {\n      route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: '{ not valid json }}}}',\n      });\n    });\n    await page.goto('/');\n    await page.evaluate(() => {\n      localStorage.setItem('jwtToken', 'some-token-that-will-be-tested');\n    });\n    await page.reload();\n    // App should not crash - should show \"unavailable\" mode (parsing error)\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    await expect(page.locator('text=Connecting')).toBeVisible();\n    // Token should be KEPT (parsing error, not auth error)\n    const token = await getToken(page);\n    expect(token).not.toBeNull();\n  });\n});\n\ntest.describe('User Fetch Errors - Protected Routes', () => {\n  test.beforeEach(async ({ page }) => {\n    await page.goto('/');\n    await page.evaluate(() => {\n      localStorage.setItem('jwtToken', 'some-token-that-will-be-tested');\n    });\n  });\n\n  test('should redirect from /settings on 401', async ({ page }) => {\n    await page.route(`${API_BASE}/user`, route => {\n      route.fulfill({\n        status: 401,\n        contentType: 'application/json',\n        body: JSON.stringify({ errors: { message: ['Unauthorized'] } }),\n      });\n    });\n    await page.goto('/settings');\n    // Should redirect to login or home, not show blank screen\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    await expect(page).not.toHaveURL('/settings');\n  });\n\n  test('should redirect from /editor on 401', async ({ page }) => {\n    await page.route(`${API_BASE}/user`, route => {\n      route.fulfill({\n        status: 401,\n        contentType: 'application/json',\n        body: JSON.stringify({ errors: { message: ['Unauthorized'] } }),\n      });\n    });\n    await page.goto('/editor');\n    // Should redirect to login or home, not show blank screen\n    await expect(page.locator('nav.navbar')).toBeVisible();\n    await expect(page).not.toHaveURL('/editor');\n  });\n\n  test('should handle 500 on loading /settings gracefully', async ({ page }) => {\n    await page.route(`${API_BASE}/user`, route => {\n      route.fulfill({\n        status: 500,\n        contentType: 'application/json',\n        body: JSON.stringify({ errors: { server: ['Internal error'] } }),\n      });\n    });\n    await page.goto('/settings');\n    // App should not crash\n    await expect(page.locator('nav.navbar')).toBeVisible();\n  });\n\n  test('should handle network error on /settings gracefully', async ({ page }) => {\n    await page.route(`${API_BASE}/user`, route => {\n      route.abort('internetdisconnected');\n    });\n    await page.goto('/settings');\n    // App should not crash\n    await expect(page.locator('nav.navbar')).toBeVisible();\n  });\n});\n"
  },
  {
    "path": "specs/e2e/xss-security.spec.ts",
    "content": "import { test, expect, Page } from '@playwright/test';\nimport { generateUniqueUser } from './helpers/auth';\nimport { registerUserViaAPI, updateUserViaAPI, createArticleViaAPI } from './helpers/api';\nimport { API_MODE } from './helpers/config';\n\ntest.beforeEach(({ }, testInfo) => {\n  testInfo.skip(!API_MODE, 'API-only: all tests use direct API calls + localStorage injection');\n});\n\n/**\n * XSS Security Tests - BASIC SMOKE TESTS ONLY\n *\n * ⚠️  IMPORTANT DISCLAIMER ⚠️\n *\n * These tests are NOT a comprehensive security audit. They only check for a few\n * common, naive XSS attack patterns and should NOT give you a false sense of security.\n *\n * What these tests DO:\n * - Verify basic protection against common XSS payloads in image URLs\n * - Check that basic sanitization is present and working for markdown / urls\n * - Catch obvious security regressions\n *\n * What these tests DO NOT:\n * - Cover all possible XSS vectors (this a whole different scope) and bypass techniques\n * - Replace a proper security audit / penetration testing\n * - Actually guarantee the application is secure against XSS\n *\n * For production applications, you should:\n * - Conduct regular security audits by professionals\n * - Use automated security scanning tools (OWASP ZAP, Burp Suite, etc.)\n * - Implement Content Security Policy (CSP) headers\n * - Keep dependencies updated (especially sanitization libraries)\n * - Follow OWASP guidelines: https://owasp.org/www-community/xss-filter-evasion-cheatsheet\n *\n * IMPORTANT: These tests inject malicious payloads directly via API (POST/PUT),\n * bypassing any UI-level sanitization. This simulates a real attack where an\n * attacker uses curl/Postman to inject malicious data directly into the backend.\n *\n * Reference: https://github.com/realworld-apps/realworld/issues/525\n */\n\n// Common XSS payloads targeting image src attribute\nconst XSS_IMAGE_PAYLOADS = [\n  {\n    name: 'onerror attribute injection',\n    payload: 'https://example.com/img.jpg\"onerror=\"alert(1)',\n  },\n  {\n    name: 'onerror with javascript protocol',\n    payload: 'https://example.com/img.jpg\"onerror=\"javascript:alert(document.cookie)',\n  },\n  {\n    name: 'onload attribute injection',\n    payload: 'https://example.com/img.jpg\"onload=\"alert(1)',\n  },\n  {\n    name: 'javascript protocol in src',\n    payload: 'javascript:alert(1)',\n  },\n  {\n    name: 'data URI with script',\n    payload: 'data:text/html,<script>alert(1)</script>',\n  },\n];\n\n// XSS payloads targeting markdown/HTML content (article body)\nconst XSS_MARKDOWN_PAYLOADS = [\n  {\n    name: 'script tag',\n    payload: '<script>alert(1)</script>',\n  },\n  {\n    name: 'img onerror',\n    payload: '<img src=x onerror=\"alert(1)\">',\n  },\n  {\n    name: 'svg onload',\n    payload: '<svg onload=\"alert(1)\">',\n  },\n  {\n    name: 'iframe srcdoc',\n    payload: '<iframe srcdoc=\"<script>alert(1)</script>\">',\n  },\n  {\n    name: 'anchor javascript href',\n    payload: '<a href=\"javascript:alert(1)\">click me</a>',\n  },\n  {\n    name: 'event handler in div',\n    payload: '<div onmouseover=\"alert(1)\">hover me</div>',\n  },\n];\n\n/**\n * Sets up a listener for any dialog (alert, confirm, prompt) on the page.\n * Returns a function that checks if any dialog was triggered.\n */\nfunction setupXssDetector(page: Page): () => boolean {\n  let xssTriggered = false;\n  page.on('dialog', async dialog => {\n    xssTriggered = true;\n    await dialog.dismiss();\n  });\n  return () => xssTriggered;\n}\n\n/**\n * Injects the JWT token into the browser's localStorage to authenticate the session.\n */\nasync function injectToken(page: Page, token: string): Promise<void> {\n  await page.goto('/');\n  await page.evaluate(t => {\n    localStorage.setItem('jwtToken', t);\n  }, token);\n}\n\ntest.describe('@security XSS Security - Image URL Injection (Direct API)', () => {\n  for (const { name, payload } of XSS_IMAGE_PAYLOADS) {\n    test(`should prevent XSS via ${name}`, async ({ page, request }) => {\n      const wasXssTriggered = setupXssDetector(page);\n      // Register user via API\n      const user = generateUniqueUser();\n      const token = await registerUserViaAPI(request, user);\n      // Inject malicious image URL directly via API (bypassing UI)\n      await updateUserViaAPI(request, token, { image: payload });\n      // Now visit the profile page as a \"victim\" viewing this profile\n      await injectToken(page, token);\n      await page.goto(`/profile/${user.username}`);\n      await expect(page).toHaveURL(new RegExp(`/profile/${user.username}`));\n      // Wait for profile to load (user-info section contains the image)\n      await page.waitForSelector('.user-info', { timeout: 10000 });\n      // Wait for any deferred/async XSS payloads to execute\n      await page.waitForTimeout(1500);\n      // Verify no XSS was triggered\n      expect(wasXssTriggered()).toBe(false);\n      // Verify the malicious payload is NOT in an executable context\n      const imgElement = page.locator('.user-img');\n      await expect(imgElement).toBeVisible();\n      // Check that onerror/onload are not attributes on the img element\n      const hasOnerror = await imgElement.evaluate(el => el.hasAttribute('onerror'));\n      expect(hasOnerror).toBe(false);\n      const hasOnload = await imgElement.evaluate(el => el.hasAttribute('onload'));\n      expect(hasOnload).toBe(false);\n    });\n  }\n\n  test('should safely render malicious payload in navbar image', async ({ page, request }) => {\n    const wasXssTriggered = setupXssDetector(page);\n    // Register and inject malicious image via API\n    const user = generateUniqueUser();\n    const token = await registerUserViaAPI(request, user);\n    const maliciousImage = 'https://x.com/img.jpg\"onerror=\"alert(document.cookie)';\n    await updateUserViaAPI(request, token, { image: maliciousImage });\n    // Inject token and navigate around to trigger navbar re-renders\n    await injectToken(page, token);\n    await page.goto('/');\n    await page.waitForTimeout(1000);\n    await page.goto('/settings');\n    await page.waitForTimeout(1000);\n    // Check navbar image doesn't have event handlers\n    const navbarImg = page.locator('nav .user-pic');\n    if (await navbarImg.isVisible()) {\n      const hasOnerror = await navbarImg.evaluate(el => el.hasAttribute('onerror'));\n      expect(hasOnerror).toBe(false);\n    }\n    expect(wasXssTriggered()).toBe(false);\n  });\n\n  test('should safely render malicious payload in article comments', async ({ page, request }) => {\n    const wasXssTriggered = setupXssDetector(page);\n    // Register and inject malicious image via API\n    const user = generateUniqueUser();\n    const token = await registerUserViaAPI(request, user);\n    const maliciousImage = 'https://x.com/img.jpg\"onerror=\"alert(1)';\n    await updateUserViaAPI(request, token, { image: maliciousImage });\n    // Inject token and go to an article\n    await injectToken(page, token);\n    await page.goto('/');\n    await page.waitForSelector('.article-preview a.preview-link, .article-preview');\n    // Click on the first article\n    const articleLink = page.locator('.article-preview a.preview-link').first();\n    if (await articleLink.isVisible()) {\n      await articleLink.click();\n      await page.waitForURL(/\\/article\\//);\n      // Wait for comment section to load and any XSS to trigger\n      await page.waitForTimeout(1500);\n      // The user's image appears in the comment form\n      const commentAuthorImg = page.locator('.comment-author-img').first();\n      if (await commentAuthorImg.isVisible()) {\n        const hasOnerror = await commentAuthorImg.evaluate(el => el.hasAttribute('onerror'));\n        expect(hasOnerror).toBe(false);\n      }\n    }\n    expect(wasXssTriggered()).toBe(false);\n  });\n});\n\ntest.describe('@security XSS Security - Article Description in Feed (Direct API)', () => {\n  const XSS_DESCRIPTION_PAYLOADS = [\n    { name: 'script tag', payload: '<script>alert(1)</script>' },\n    { name: 'img onerror', payload: '<img src=x onerror=\"alert(1)\">' },\n    { name: 'svg onload', payload: '<svg onload=\"alert(1)\">' },\n  ];\n  for (const { name, payload } of XSS_DESCRIPTION_PAYLOADS) {\n    test(`should sanitize ${name} in article description`, async ({ page, request }) => {\n      const wasXssTriggered = setupXssDetector(page);\n      // Register user and create article with malicious description via API\n      const user = generateUniqueUser();\n      const token = await registerUserViaAPI(request, user);\n      const timestamp = Date.now();\n      await createArticleViaAPI(request, token, {\n        title: `XSS Desc Test ${timestamp}`,\n        description: `Before: ${payload} After`,\n        body: 'Normal body content',\n      });\n      // Inject token and visit user's profile to see their articles\n      await injectToken(page, token);\n      await page.goto(`/profile/${user.username}`);\n      // Wait for article preview to render\n      await page.waitForSelector('.article-preview', { timeout: 10000 });\n      // Wait for any XSS to trigger\n      await page.waitForTimeout(1500);\n      // Verify no XSS was triggered\n      expect(wasXssTriggered()).toBe(false);\n      // Check the description doesn't contain executable elements\n      const description = page.locator('.article-preview p').first();\n      if (await description.isVisible()) {\n        // The payload should be visible as escaped text, not executed\n        const text = await description.textContent();\n        expect(text).toContain('Before:');\n        // Verify no script tags were injected into the DOM\n        const scriptCount = await page.locator('.article-preview script').count();\n        expect(scriptCount).toBe(0);\n      }\n    });\n  }\n});\n\ntest.describe('@security XSS Security - Article Body Markdown (Direct API)', () => {\n  for (const { name, payload } of XSS_MARKDOWN_PAYLOADS) {\n    test(`should sanitize ${name} in article body`, async ({ page, request }) => {\n      const wasXssTriggered = setupXssDetector(page);\n      // Register user and create article with malicious body via API\n      const user = generateUniqueUser();\n      const token = await registerUserViaAPI(request, user);\n      const timestamp = Date.now();\n      const slug = await createArticleViaAPI(request, token, {\n        title: `XSS Test ${timestamp}`,\n        description: 'Testing XSS protection',\n        body: `Before payload: ${payload} After payload`,\n      });\n      // Inject token FIRST (session isolation - must be same user to view article)\n      await injectToken(page, token);\n      // Now visit the article page as the authenticated user\n      await page.goto(`/article/${slug}`);\n      // Wait for content to render\n      await page.waitForSelector('.article-content', { timeout: 10000 });\n      // Wait for any XSS to trigger\n      await page.waitForTimeout(1500);\n      // Verify no XSS was triggered\n      expect(wasXssTriggered()).toBe(false);\n      // Check the article body container for dangerous elements/attributes\n      const articleBody = page.locator('.article-content');\n      await expect(articleBody).toBeVisible();\n      // Verify no script tags exist\n      const scriptCount = await articleBody.locator('script').count();\n      expect(scriptCount).toBe(0);\n      // Verify no elements with dangerous event handlers\n      const dangerousHandlers = ['onerror', 'onload', 'onmouseover', 'onclick', 'onfocus'];\n      for (const handler of dangerousHandlers) {\n        const elementsWithHandler = await articleBody.locator(`[${handler}]`).count();\n        expect(elementsWithHandler).toBe(0);\n      }\n    });\n  }\n});\n"
  }
]