diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 58bc32e4..4163f7f1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,6 +6,11 @@ updates: interval: daily open-pull-requests-limit: 10 groups: + # Keep the framework and its lint rules on the same release. + next: + patterns: + - "next" + - "eslint-config-next" # react, react-dom and their @types must move together: bumping one # alone breaks npm ci on peer-dependency conflicts. react: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cc3f90f..ea5ddccf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: # Contract tests stub modules with module.registerHooks (Node 22.15+). node-version: "24" @@ -106,7 +106,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: "24" cache: npm @@ -124,7 +124,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: "24" cache: npm @@ -144,7 +144,7 @@ jobs: # failures can be opened with `npx playwright show-trace`. - name: Upload Playwright traces if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: playwright-traces-${{ github.run_id }}-${{ github.run_attempt }} path: test-results/ diff --git a/.github/workflows/scrape.yml b/.github/workflows/scrape.yml index 7f4677af..a3f79b3e 100644 --- a/.github/workflows/scrape.yml +++ b/.github/workflows/scrape.yml @@ -195,7 +195,7 @@ jobs: - name: Upload encrypted run diagnostics if: always() && steps.encrypted.outcome == 'success' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: pipeline-report-${{ github.run_id }}-${{ github.run_attempt }} path: .pipeline-cache/state.enc diff --git a/package-lock.json b/package-lock.json index b36baae5..57d6fc79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,11 @@ "name": "highlander-hub", "version": "0.1.0", "dependencies": { - "@supabase/supabase-js": "^2.117.2", + "@supabase/supabase-js": "^2.117.3", "@vercel/analytics": "^2.0.1", "clsx": "^2.1.1", - "motion": "^14.0.0", - "next": "16.3.8", + "motion": "^14.1.0", + "next": "16.4.0", "react": "^19.3.0", "react-dom": "^19.3.0", "react-icons": "^5.7.0", @@ -20,13 +20,13 @@ }, "devDependencies": { "@electric-sql/pglite": "^0.5.8", - "@playwright/test": "^1.63.0", - "@types/node": "^26.6.4", + "@playwright/test": "^1.64.0", + "@types/node": "^26.6.5", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "autoprefixer": "^10.6.1", "eslint": "9.39.4", - "eslint-config-next": "16.3.8", + "eslint-config-next": "16.4.0", "postcss": "8.5.29", "tailwindcss": "^3.4.6", "typescript": "^5.5.3" @@ -1185,15 +1185,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz", - "integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.0.tgz", + "integrity": "sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.8.tgz", - "integrity": "sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.4.0.tgz", + "integrity": "sha512-ffeflB/dL8RrvWejQximw01pOpKHN692Y3FjDB82DCd0YVvXvBD6vY7mcZp27yvSVFVM6wA73xVodEKF4spizA==", "dev": true, "license": "MIT", "dependencies": { @@ -1232,9 +1232,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz", - "integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.0.tgz", + "integrity": "sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==", "cpu": [ "arm64" ], @@ -1248,9 +1248,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz", - "integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.0.tgz", + "integrity": "sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==", "cpu": [ "x64" ], @@ -1264,12 +1264,15 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz", - "integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.0.tgz", + "integrity": "sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1280,12 +1283,15 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz", - "integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.0.tgz", + "integrity": "sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1296,12 +1302,15 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz", - "integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.0.tgz", + "integrity": "sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1312,12 +1321,15 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz", - "integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.0.tgz", + "integrity": "sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1328,9 +1340,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz", - "integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.0.tgz", + "integrity": "sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==", "cpu": [ "arm64" ], @@ -1344,9 +1356,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz", - "integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.0.tgz", + "integrity": "sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==", "cpu": [ "x64" ], @@ -1408,13 +1420,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", - "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.63.0" + "playwright": "1.64.0" }, "bin": { "playwright": "cli.js" @@ -1431,9 +1443,9 @@ "license": "MIT" }, "node_modules/@supabase/auth-js": { - "version": "2.117.2", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.117.2.tgz", - "integrity": "sha512-Z3WnGwrphYZubrLbxp5Iv0tLA9A5GhvKzJ/ZXglxqwd2QEH2R4dXRltjVJ8sIn4xEW2BSFIGT0yALVJIgZeDYw==", + "version": "2.117.3", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.117.3.tgz", + "integrity": "sha512-XOZV+jllcU2Hzij8SwgM78zl0lOEr6BTjzi2MYkBb39nOAQzb5AelGpMUvVj7lwGSB11JmpAs/FEQroAyFzWEA==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -1443,9 +1455,9 @@ } }, "node_modules/@supabase/functions-js": { - "version": "2.117.2", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.117.2.tgz", - "integrity": "sha512-6DT4ZIjmZxa9ANKaBIrjc86AyKrX8M376ynvNeOBBVad3eP7x3UHqWfDR2ynFkHGzCuOpr1A+kObmnahxuwuog==", + "version": "2.117.3", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.117.3.tgz", + "integrity": "sha512-6GuizbP4N5xWaxEVUsI+UQpJdmMmn0t3sYNpObo9gCAldvM22gFjnYzRVmkHxp5ylU21azEYmiWVwk9iDgCMkQ==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -1461,9 +1473,9 @@ "license": "MIT" }, "node_modules/@supabase/postgrest-js": { - "version": "2.117.2", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.117.2.tgz", - "integrity": "sha512-V1Qhn+M8xzJqCasOxHZ2KG19Fj39PxU7weEAmOK65/KTsWCRXboB4hcQwFvxRvM7ZikeP9IZWNvQkfjvSxzYFw==", + "version": "2.117.3", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.117.3.tgz", + "integrity": "sha512-NpyTgullu6tw11ojYKTdZg9HannZ4mu4FUlhc9V5zDXOVllUJzEmuyLM+WWoYF0BurfrRvarimpm3SR8P0Gzfw==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -1473,9 +1485,9 @@ } }, "node_modules/@supabase/realtime-js": { - "version": "2.117.2", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.117.2.tgz", - "integrity": "sha512-lYXSAIg3eAKA58riUED6Vb+TCzF8jtx18uOoiCaVJ7ZMre6FWBpwB1MPeW+B6vykJT/hPdiuqwcSB7ILMS21ew==", + "version": "2.117.3", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.117.3.tgz", + "integrity": "sha512-4W4ISbreRygNXbhwqxeqyuRul8RPx+SF+Hp2IT8wctebQEHSVCULfKatiRiEhmasfoXvYm2AHV4XFsOxhoousA==", "license": "MIT", "dependencies": { "@supabase/phoenix": "0.4.5", @@ -1486,9 +1498,9 @@ } }, "node_modules/@supabase/storage-js": { - "version": "2.117.2", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.117.2.tgz", - "integrity": "sha512-8gAJoVaxZa/War2kFRfJMxGk4M190Q7lJ70BofwGlRxU/u9pIodd6XyTvmXkxOKXWuIYtkDaYPZrIAUt1T/7FQ==", + "version": "2.117.3", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.117.3.tgz", + "integrity": "sha512-QhUxFC33Crlw6WDeA4hLADdi1PzjI3c4UJs7ivWHHEnU7ulmCWEZSDaRCuIfmsjUwbHYaIfRNFQNsm+IsGYS6w==", "license": "MIT", "dependencies": { "iceberg-js": "^0.8.1", @@ -1499,16 +1511,16 @@ } }, "node_modules/@supabase/supabase-js": { - "version": "2.117.2", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.117.2.tgz", - "integrity": "sha512-eSG2VKnHR+Clp1PmidZ1/weJ8PJwoybjva3L2GgKqFG4YDS1Iqmc61psKGZP5xw6OMT2O7ZorPR42PY6q1BOXg==", + "version": "2.117.3", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.117.3.tgz", + "integrity": "sha512-K+f8PimXDunPWQ63CKRXaF5pbVPJxmfw5ryAnh0s9S9PkwcuqBjiSpIaqpF7KLDljeYwXIL85ST8QxaO0+XuvA==", "license": "MIT", "dependencies": { - "@supabase/auth-js": "2.117.2", - "@supabase/functions-js": "2.117.2", - "@supabase/postgrest-js": "2.117.2", - "@supabase/realtime-js": "2.117.2", - "@supabase/storage-js": "2.117.2" + "@supabase/auth-js": "2.117.3", + "@supabase/functions-js": "2.117.3", + "@supabase/postgrest-js": "2.117.3", + "@supabase/realtime-js": "2.117.3", + "@supabase/storage-js": "2.117.3" }, "engines": { "node": ">=22.0.0" @@ -1564,9 +1576,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.6.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", - "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", "dev": true, "license": "MIT", "dependencies": { @@ -3359,21 +3371,21 @@ } }, "node_modules/eslint-config-next": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.8.tgz", - "integrity": "sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.4.0.tgz", + "integrity": "sha512-2RHLYswzc+hNRktWAjIL6XAvwb1cKcjLawJ5KWywAxn/JivNHtG42ZigL1yobVXDqsimuuL2F9jcEcnHFzbppQ==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.3.8", + "@next/eslint-plugin-next": "16.4.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", - "eslint-plugin-react-hooks": "^7.0.0", + "eslint-plugin-react-hooks": "^7.1.0", "globals": "16.4.0", - "typescript-eslint": "^8.46.0" + "typescript-eslint": "^8.56.0" }, "peerDependencies": { "eslint": ">=9.0.0", @@ -3896,13 +3908,13 @@ } }, "node_modules/framer-motion": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.0.0.tgz", - "integrity": "sha512-QAtHmybrDFSliyW1sexMtSQTQd+1jw2ajibEmzpuI4XdPVzQkn+dAzJMm2sW3pl9MJuSS4xN51djQ7huhlP/VA==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.1.0.tgz", + "integrity": "sha512-QsdZSdWoyQfUmgi+GZfow8Du5COG6VRYEo9FlgKUPgH87/Le/fYekHCAKwLdYKHdCZcZfWHg4iOxlD6RoqXciQ==", "license": "MIT", "dependencies": { - "motion-dom": "14.0.0", - "motion-utils": "14.0.0", + "motion-dom": "14.1.0", + "motion-utils": "14.1.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -5010,12 +5022,12 @@ } }, "node_modules/motion": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/motion/-/motion-14.0.0.tgz", - "integrity": "sha512-oGc2lkge2lkRZCP+J8/JsnYu4NFjqP1MtXirNdetn3QbvK2JqwM9A200EdT4C598KfzDhjLT12zF21xDM4Oilw==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-14.1.0.tgz", + "integrity": "sha512-lsCsW9uJ+iMJj8MSiLccpUC+ZUpoSspaX6Pm78KrnZKMioCPAPG6WeG1d1d/Ywg+z1mny+STP6VyEYW+3nAqNw==", "license": "MIT", "dependencies": { - "framer-motion": "14.0.0", + "framer-motion": "14.1.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -5032,18 +5044,18 @@ } }, "node_modules/motion-dom": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.0.0.tgz", - "integrity": "sha512-aU3ApXo1yTpeUi3JU+jbDk2TELjxPE3MbBMUP7nZHlARb6RweJK+ojmLjpuB0YsY6zhbBl1YWGo8LMsI1/Llqw==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.1.0.tgz", + "integrity": "sha512-FyFLkNUAAETzCvf851mZcl+v+kaZrKq+UM+XmkY4G8n+ny/WYfiWh9j0fhSQ1KOKVQrM9/cLO11THO2k71HJlg==", "license": "MIT", "dependencies": { - "motion-utils": "14.0.0" + "motion-utils": "14.1.0" } }, "node_modules/motion-utils": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.0.0.tgz", - "integrity": "sha512-jBsyjVxTTRZNeuvd/v+btVXr90LQkYl4HhRu9T2ARc+PMnvkmjsjRVeAg4Qm10b6TlTw+kykWI8gu/6LuvpLSQ==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.1.0.tgz", + "integrity": "sha512-xd4FeWOnnXsJHmAaRCLNbOS7mEaNOangjMt3Pl241H07f1kxW48fADQ/8O5hLLNhf1qdlozgY3PPAHBdOoU7AA==", "license": "MIT" }, "node_modules/ms": { @@ -5107,12 +5119,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz", - "integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/next/-/next-16.4.0.tgz", + "integrity": "sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==", "license": "MIT", "dependencies": { - "@next/env": "16.3.8", + "@next/env": "16.4.0", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -5126,14 +5138,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.8", - "@next/swc-darwin-x64": "16.3.8", - "@next/swc-linux-arm64-gnu": "16.3.8", - "@next/swc-linux-arm64-musl": "16.3.8", - "@next/swc-linux-x64-gnu": "16.3.8", - "@next/swc-linux-x64-musl": "16.3.8", - "@next/swc-win32-arm64-msvc": "16.3.8", - "@next/swc-win32-x64-msvc": "16.3.8", + "@next/swc-darwin-arm64": "16.4.0", + "@next/swc-darwin-x64": "16.4.0", + "@next/swc-linux-arm64-gnu": "16.4.0", + "@next/swc-linux-arm64-musl": "16.4.0", + "@next/swc-linux-x64-gnu": "16.4.0", + "@next/swc-linux-x64-musl": "16.4.0", + "@next/swc-win32-arm64-msvc": "16.4.0", + "@next/swc-win32-x64-msvc": "16.4.0", "sharp": "^0.35.4" }, "peerDependencies": { @@ -5489,13 +5501,13 @@ } }, "node_modules/playwright": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", - "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.63.0" + "playwright-core": "1.64.0" }, "bin": { "playwright": "cli.js" @@ -5505,9 +5517,9 @@ } }, "node_modules/playwright-core": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", - "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", "devOptional": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 11c07100..5e0aa120 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,11 @@ "pretest:unit": "node scripts/generate-public-clubs.mjs" }, "dependencies": { - "@supabase/supabase-js": "^2.117.2", + "@supabase/supabase-js": "^2.117.3", "@vercel/analytics": "^2.0.1", "clsx": "^2.1.1", - "motion": "^14.0.0", - "next": "16.3.8", + "motion": "^14.1.0", + "next": "16.4.0", "react": "^19.3.0", "react-dom": "^19.3.0", "react-icons": "^5.7.0", @@ -29,13 +29,13 @@ }, "devDependencies": { "@electric-sql/pglite": "^0.5.8", - "@playwright/test": "^1.63.0", - "@types/node": "^26.6.4", + "@playwright/test": "^1.64.0", + "@types/node": "^26.6.5", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "autoprefixer": "^10.6.1", "eslint": "9.39.4", - "eslint-config-next": "16.3.8", + "eslint-config-next": "16.4.0", "postcss": "8.5.29", "tailwindcss": "^3.4.6", "typescript": "^5.5.3" diff --git a/pipeline/README.md b/pipeline/README.md index f024f579..5cdab8d2 100644 --- a/pipeline/README.md +++ b/pipeline/README.md @@ -413,3 +413,8 @@ over half of a batch reports deleted is treated as an outage and changes nothing Withdrawn sources carry `assessment.deleted`, which publication skips so the archived copy is not republished. Progress is kept in `data/post_rechecks.json`. A post is checked once, so a deletion after the check is not seen. +Each batch saves its check results before marking the paid run consumed. +An interrupted batch reuses its original run and post list; completed batches +use the next due list. Replaying a run cannot count as a second deletion +confirmation. Empty or unrelated output stops further batches and retains the +paid run for investigation rather than silently buying the same lookup again. diff --git a/pipeline/apify_posts.py b/pipeline/apify_posts.py index ea611d4d..841242f2 100644 --- a/pipeline/apify_posts.py +++ b/pipeline/apify_posts.py @@ -154,7 +154,10 @@ def details(self, codes: list[str], *, max_charge: float, timeout: int) -> tuple # This intent stays unconsumed even for FAILED runs: replacing it within # a batch could spend its reserved detail ceiling twice. A fresh plan's # cycle-specific filename permits a new attempt for incomplete profiles. - codes = saved.get("usernames", sorted(codes)) + if saved and not saved.get("consumed"): + codes = saved.get("usernames", sorted(codes)) + else: + codes = sorted(codes) client = ApifyClient(self.session.headers["Authorization"].removeprefix("Bearer "), path) run = client._run(hpix_input(posts=[f"https://www.instagram.com/p/{code}/" for code in codes]), {"usernames": codes, "actor_id": HPIX_ACTOR_ID, diff --git a/pipeline/post_recheck.py b/pipeline/post_recheck.py index bd2f393e..79c5dd83 100644 --- a/pipeline/post_recheck.py +++ b/pipeline/post_recheck.py @@ -120,7 +120,12 @@ def main() -> None: registry = assessed_events.load_registry() state = read_json(STATE_FILE) if STATE_FILE.exists() else {} due = due_sources(events, registry, state, now) - if not due: + details_file = RUN_FILE.with_name(RUN_FILE.stem + "-details.json") + saved = read_json(details_file) if details_file.exists() else {} + # Finish already-paid work before buying a new batch, even if its events + # have expired or its check state was saved just before an interruption. + recovery = saved.get("usernames", []) if saved and not saved.get("consumed") else [] + if not due and not recovery: log.info("post recheck: nothing due") return media_ids = [key.rsplit(":", 1)[-1] for key in due] @@ -131,12 +136,12 @@ def main() -> None: token = os.environ.get("APIFY_TOKEN", "") api = ApifyClient(token, RUN_FILE) results: dict[str, str] = {} - pending = sorted(by_code) - for i in range(0, len(pending), BATCH_SIZE): - batch = pending[i:i + BATCH_SIZE] + withdrawn = 0 + pending = sorted(set(by_code) - set(recovery)) + batches = ([recovery] if recovery else []) + [ + pending[i:i + BATCH_SIZE] for i in range(0, len(pending), BATCH_SIZE)] + for batch in batches: run, rows = api.details(batch, max_charge=MAX_CHARGE_USD, timeout=600) - details_file = RUN_FILE.with_name(RUN_FILE.stem + "-details.json") - write_json(details_file, {**read_json(details_file), "consumed": True}) # Count against the lookup's own ceiling; a charge-limited run may # have stopped before reaching every post, so nothing can be concluded. if run.get("status") != "SUCCEEDED": @@ -144,33 +149,50 @@ def main() -> None: reason = charge_limit_reason(run, MAX_CHARGE_USD, len(rows), details=True) if reason: raise CollectionDeferred(f"Post recheck: {reason}") - batch_result = classify(rows, set(pending)) + if not rows: + raise RuntimeError(f"Post recheck run {run['id']}: empty output; paid batch retained") + for row in rows: + node = row.get("data") if isinstance(row, dict) else None + code = (node.get("code") or node.get("shortcode")) if isinstance(node, dict) else None + match = POST_URL.search(str(row.get("input") or "")) if isinstance(row, dict) else None + identities = {value for value in (code, match.group(1) if match else None) if value} + if not identities or not identities.issubset(batch): + raise RuntimeError(f"Post recheck run {run['id']}: unrelated or unidentifiable output; paid batch retained") + batch_result = classify(rows, set(batch)) gone = sum(1 for v in batch_result.values() if v == "missing") if len(batch) >= 4 and gone * 2 > len(batch): # Posts this old almost never vanish together; the lookup is broken. raise RuntimeError(f"Post recheck: {gone} of {len(batch)} posts reported deleted; " "treating the response as an outage and changing nothing") results.update(batch_result) - - updates = [] - for code, outcome in results.items(): - media_id = by_code[code] - key = f"instagram:post:{media_id}" - entry = state.get(media_id) or {} - if outcome == "present": - state[media_id] = {"status": "present", "checked_at": iso(now)} - continue - misses = entry.get("misses", 0) + 1 - state[media_id] = {"status": "missing", "misses": misses, "checked_at": iso(now)} - log.info("post %s (%s): reported deleted (%d/%d)", media_id, code, misses, CONFIRMATIONS) - if misses >= CONFIRMATIONS: - updates.append(withdrawal(key, registry[key], now)) - state[media_id]["status"] = "withdrawn" - write_json(STATE_FILE, state) - if updates: - log.info("post recheck: withdrawing %d deleted post(s): %s", len(updates), - ", ".join(u["source_key"] for u in updates)) - log.info("Assessed publication: %s", assessed_events.publish(updates)) + updates = [] + for code, outcome in batch_result.items(): + # Recovery may contain sources that are no longer due. Its paid + # dataset is still consumed, but expired listings are left alone. + if code not in by_code: + continue + media_id = by_code[code] + key = f"instagram:post:{media_id}" + entry = state.get(media_id) or {} + if entry.get("run_id") == run["id"]: + continue + state[media_id] = {"status": outcome, "checked_at": iso(now), "run_id": run["id"]} + if outcome == "missing": + misses = entry.get("misses", 0) + 1 + state[media_id]["misses"] = misses + log.info("post %s (%s): reported deleted (%d/%d)", media_id, code, misses, CONFIRMATIONS) + if misses >= CONFIRMATIONS: + updates.append(withdrawal(key, registry[key], now)) + state[media_id]["status"] = "withdrawn" + if updates: + log.info("post recheck: withdrawing %d deleted post(s): %s", len(updates), + ", ".join(u["source_key"] for u in updates)) + log.info("Assessed publication: %s", assessed_events.publish(updates)) + withdrawn += len(updates) + # Persist each batch before marking its paid run consumed. If either + # write fails, replay retains the run ID and cannot double-count misses. + write_json(STATE_FILE, state) + write_json(details_file, {**read_json(details_file), "consumed": True}) log.info("post recheck: %d looked up, %d present, %d reported deleted, %d withdrawn", len(results), sum(v == "present" for v in results.values()), - sum(v == "missing" for v in results.values()), len(updates)) + sum(v == "missing" for v in results.values()), withdrawn) diff --git a/pipeline/requirements.txt b/pipeline/requirements.txt index f987f5b9..088ebd59 100644 --- a/pipeline/requirements.txt +++ b/pipeline/requirements.txt @@ -4,6 +4,6 @@ requests>=2.34.2 supabase>=2.32.0 python-dotenv>=1.2.4 tzdata>=2026.5 -google-genai>=2.28.0 +google-genai>=2.29.0 Pillow>=12.3.0,<13 zxing-cpp>=3.1.1,<4 # decode flyer QR codes without another API call diff --git a/pipeline/tests/test_post_recheck.py b/pipeline/tests/test_post_recheck.py index d076c1e8..e58aa76d 100644 --- a/pipeline/tests/test_post_recheck.py +++ b/pipeline/tests/test_post_recheck.py @@ -2,15 +2,18 @@ from __future__ import annotations import sys +import tempfile import unittest from datetime import datetime, timedelta, timezone from pathlib import Path +from unittest.mock import MagicMock, patch PIPELINE_ROOT = Path(__file__).resolve().parents[1] if str(PIPELINE_ROOT) not in sys.path: sys.path.insert(0, str(PIPELINE_ROOT)) import post_recheck as pr # noqa: E402 +import apify_posts as apify # noqa: E402 NOW = datetime(2026, 10, 9, 12, tzinfo=timezone.utc) @@ -62,5 +65,219 @@ def test_publishes_nothing_and_marks_the_source_deleted(self): self.assertEqual("complete", update["assessment"]["status"]) +class RecheckIntegration(unittest.TestCase): + """Exercise real batching, paid-run recovery and durable check state.""" + + def setUp(self): + directory = self.enterContext(tempfile.TemporaryDirectory()) + self.state_file = Path(directory) / "state.json" + self.run_file = Path(directory) / "recheck.json" + self.details_file = Path(directory) / "recheck-details.json" + self.enterContext(patch.object(pr, "STATE_FILE", self.state_file)) + self.enterContext(patch.object(pr, "RUN_FILE", self.run_file)) + self.enterContext(patch.dict(pr.os.environ, {"APIFY_TOKEN": "test"})) + self.clock = self.enterContext(patch.object(pr, "datetime", wraps=datetime)) + self.clock.now.return_value = NOW + self.events = [] + self.registry = {} + self.codes = {} + self.database = MagicMock() + query = self.database.table.return_value.select.return_value.eq.return_value + query.gte.return_value.lte.return_value.execute.return_value.data = self.events + self.enterContext(patch("db.client", return_value=self.database)) + self.enterContext(patch("assessed_events.load_registry", return_value=self.registry)) + self.publish = self.enterContext(patch("assessed_events.publish")) + self.enterContext(patch.object(pr, "shortcodes", side_effect=lambda ids: { + key: self.codes[key] for key in ids if key in self.codes})) + self.purchases = [] + self.runs = {} + self.datasets = {} + self.requests = [] + self.response_rows = None + self.fail_purchase = None + self.fail_dataset = None + self.run_updates = {} + self.enterContext(patch.object(apify.ApifyClient, "request", new=self.request)) + + def add_posts(self, count, offset=0): + for i in range(offset, offset + count): + key = str(i) + self.codes[key] = f"POST_{i:03d}" + self.events.append(event(key, 2)) + self.registry[f"instagram:post:{key}"] = source(key) + + def request(self, method, path, **kwargs): + self.requests.append((method, path)) + if method == "POST": + if len(self.purchases) == self.fail_purchase: + raise TimeoutError("ambiguous paid start") + codes = [url.rsplit("/", 2)[1] for url in kwargs["json"]["posts"]] + run_id = str(len(self.purchases)) + self.purchases.append(codes) + self.datasets[run_id] = (self.response_rows if self.response_rows is not None else + [{"kind": "post", "data": {"code": code}} for code in codes]) + run = {"id": run_id, "actId": apify.HPIX_ACTOR_ID, "status": "SUCCEEDED", + "startedAt": NOW.isoformat(), "defaultDatasetId": run_id, + "options": {"maxTotalChargeUsd": kwargs["params"]["maxTotalChargeUsd"]}, + "chargedEventCounts": {"individual_post_scraped": len(codes), "apify-actor-start": 1}, + "pricingInfo": {"pricingPerEvent": {"actorChargeEvents": { + "individual_post_scraped": {"eventPriceUsd": .00149}, + "apify-actor-start": {"eventPriceUsd": .00005}}}}} + run.update(self.run_updates) + self.runs[run_id] = run + return {"data": run} + if path.startswith("actor-runs/"): + return {"data": self.runs[path.split("/")[1]]} + if path.startswith("datasets/"): + if path.split("/")[1] == self.fail_dataset: + raise RuntimeError("dataset temporarily unavailable") + return self.datasets[path.split("/")[1]] + raise AssertionError((method, path)) + + def test_distinct_batches_and_later_cycle_do_not_recheck_present_posts(self): + self.add_posts(60) + pr.main() + self.assertEqual([50, 10], [len(batch) for batch in self.purchases]) + self.assertEqual(60, len(set(code for batch in self.purchases for code in batch))) + self.assertEqual(60, len(pr.read_json(self.state_file))) + pr.main() + self.assertEqual(2, len(self.purchases)) + self.add_posts(1, offset=60) + pr.main() + self.assertEqual(["POST_060"], self.purchases[-1]) + + def test_completed_legacy_list_does_not_replace_new_work(self): + pr.write_json(self.details_file, {"consumed": True, "usernames": ["EXPIRED"]}) + self.add_posts(1) + pr.main() + self.assertEqual([["POST_000"]], self.purchases) + + def test_state_write_failure_replays_paid_run_without_another_purchase(self): + self.add_posts(1) + write = pr.write_json + def fail_state(path, value): + if path == self.state_file: + raise OSError("disk unavailable") + write(path, value) + with patch.object(pr, "write_json", side_effect=fail_state): + with self.assertRaises(OSError): + pr.main() + self.assertFalse(pr.read_json(self.details_file)["consumed"]) + pr.main() + self.assertEqual(1, len(self.purchases)) + self.assertEqual("present", pr.read_json(self.state_file)["0"]["status"]) + + def test_later_batch_failure_keeps_completed_checks(self): + self.add_posts(60) + self.fail_purchase = 1 + with self.assertRaises(TimeoutError): + pr.main() + self.assertEqual(50, len(pr.read_json(self.state_file))) + with self.assertRaisesRegex(RuntimeError, "outcome unknown"): + pr.main() + self.assertEqual(1, len(self.purchases)) + + def test_pending_paid_run_is_consumed_even_when_its_events_expired(self): + self.add_posts(1) + with patch.object(pr, "write_json", side_effect=OSError("disk unavailable")): + with self.assertRaises(OSError): + pr.main() + self.events.clear() + pr.main() + self.assertEqual(1, len(self.purchases)) + self.assertTrue(pr.read_json(self.details_file)["consumed"]) + + def test_second_batch_resumes_original_list_after_due_work_changes(self): + self.add_posts(60) + self.fail_dataset = "1" + with self.assertRaisesRegex(RuntimeError, "dataset temporarily unavailable"): + pr.main() + self.assertEqual(50, len(pr.read_json(self.state_file))) + self.add_posts(1, offset=60) + self.fail_dataset = None + pr.main() + self.assertEqual([50, 10, 1], [len(batch) for batch in self.purchases]) + self.assertEqual(["POST_060"], self.purchases[-1]) + self.assertEqual(61, len(pr.read_json(self.state_file))) + + def test_failed_or_charge_limited_run_is_not_consumed_or_rebought(self): + self.add_posts(60) + for changes in ({"status": "FAILED"}, {"options": {"maxTotalChargeUsd": .05}}): + with self.subTest(changes=changes): + self.run_updates = changes + if self.details_file.exists(): + self.details_file.unlink() + before = len(self.purchases) + with self.assertRaises(RuntimeError): + pr.main() + self.assertFalse(pr.read_json(self.details_file)["consumed"]) + with self.assertRaises(RuntimeError): + pr.main() + self.assertEqual(before + 1, len(self.purchases)) + self.assertFalse(self.state_file.exists()) + + def test_withdrawal_failure_replays_paid_results_before_marking_withdrawn(self): + self.add_posts(1) + pr.write_json(self.state_file, {"0": {"status": "missing", "misses": 1, + "checked_at": (NOW - timedelta(hours=8)).isoformat(), "run_id": "old"}}) + self.response_rows = [{"kind": "post", "input": "https://www.instagram.com/p/POST_000/", + "error": "Post deleted"}] + self.publish.side_effect = RuntimeError("database unavailable") + with self.assertRaisesRegex(RuntimeError, "database unavailable"): + pr.main() + self.assertEqual("missing", pr.read_json(self.state_file)["0"]["status"]) + self.assertFalse(pr.read_json(self.details_file)["consumed"]) + self.publish.side_effect = None + pr.main() + self.assertEqual(1, len(self.purchases)) + self.assertEqual("withdrawn", pr.read_json(self.state_file)["0"]["status"]) + + def test_matching_rate_limit_stays_unknown_without_withdrawing(self): + self.add_posts(1) + self.response_rows = [{"input": "https://www.instagram.com/p/POST_000/", "error": "Rate limited"}] + pr.main() + self.assertEqual({}, pr.read_json(self.state_file)) + self.assertTrue(pr.read_json(self.details_file)["consumed"]) + self.publish.assert_not_called() + + def test_unrelated_or_empty_output_stops_without_buying_more_batches(self): + self.add_posts(60) + for rows in ([{"kind": "post", "data": {"code": "UNRELATED"}}], []): + with self.subTest(rows=rows): + self.response_rows = rows + if self.details_file.exists(): + self.details_file.unlink() + before = len(self.purchases) + with self.assertRaisesRegex(RuntimeError, "output"): + pr.main() + self.assertEqual(before + 1, len(self.purchases)) + with self.assertRaisesRegex(RuntimeError, "output"): + pr.main() + self.assertEqual(before + 1, len(self.purchases)) + self.assertFalse(self.state_file.exists()) + + def test_missing_confirmation_is_not_counted_twice_after_consumption_write_failure(self): + self.add_posts(1) + self.response_rows = [{"kind": "post", "input": "https://www.instagram.com/p/POST_000/", + "error": "Post deleted"}] + write = pr.write_json + def fail_consumption(path, value): + if path == self.details_file and value.get("consumed"): + raise OSError("disk unavailable") + write(path, value) + with patch.object(pr, "write_json", side_effect=fail_consumption): + with self.assertRaises(OSError): + pr.main() + self.clock.now.return_value = NOW + timedelta(hours=8) + pr.main() + self.assertEqual(1, len(self.purchases)) + self.assertEqual(1, pr.read_json(self.state_file)["0"]["misses"]) + self.publish.assert_not_called() + pr.main() + self.assertEqual(2, len(self.purchases)) + self.assertEqual("withdrawn", pr.read_json(self.state_file)["0"]["status"]) + self.publish.assert_called_once() + + if __name__ == "__main__": unittest.main()