Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b54bbae65 | |||
| ce9fcfb362 | |||
| 59893908e2 | |||
| 2b78fcf731 |
@@ -0,0 +1,161 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, dev]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: "Branch or ref to run CI against"
|
||||||
|
required: false
|
||||||
|
default: "main"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-typecheck:
|
||||||
|
name: Lint & Typecheck
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: '9.15.4'
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: pnpm typecheck
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: '9.15.4'
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: pnpm test
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint-typecheck, test]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: '9.15.4'
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
docker:
|
||||||
|
name: Build & Push Docker Images
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Generate image tag
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
TAG="pr-${{ github.event.pull_request.number }}-${GITHUB_SHA::7}"
|
||||||
|
else
|
||||||
|
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
|
||||||
|
fi
|
||||||
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Image tag: $TAG"
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.farh.net
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ gitea.token }}
|
||||||
|
|
||||||
|
- name: Build and push API image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
target: runner
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
|
||||||
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
|
||||||
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:api
|
||||||
|
cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max
|
||||||
|
|
||||||
|
- name: Build and push Migrate image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
target: migrate
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}
|
||||||
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }}
|
||||||
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate
|
||||||
|
cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max
|
||||||
|
|
||||||
|
- name: Build and push Seed image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
target: seed
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}
|
||||||
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }}
|
||||||
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:seed
|
||||||
|
cache-to: type=registry,ref=git.farh.net/groombook/cache:seed,mode=max
|
||||||
|
|
||||||
|
- name: Build and push Reset image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
target: reset
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}
|
||||||
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
|
||||||
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:reset
|
||||||
|
cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max
|
||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine AS base
|
FROM node:22-alpine AS base
|
||||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ RUN mkdir -p /home/node/.cache/node/corepack
|
|||||||
COPY apps/api/ apps/api/
|
COPY apps/api/ apps/api/
|
||||||
RUN pnpm --filter @groombook/api build
|
RUN pnpm --filter @groombook/api build
|
||||||
|
|
||||||
FROM node:20-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|||||||
@@ -28,12 +28,6 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
|
|||||||
| TC-API-1.1 | Login via OIDC | POST to OIDC provider callback, verify JWT token issued | 200 OK, JWT returned with valid claims |
|
| TC-API-1.1 | Login via OIDC | POST to OIDC provider callback, verify JWT token issued | 200 OK, JWT returned with valid claims |
|
||||||
| TC-API-1.2 | Session persistence | Make authenticated request, verify session token valid | 200 OK, request succeeds |
|
| TC-API-1.2 | Session persistence | Make authenticated request, verify session token valid | 200 OK, request succeeds |
|
||||||
| TC-API-1.3 | Logout | Call logout endpoint, verify token invalidated | 200 OK, subsequent requests return 401 |
|
| TC-API-1.3 | Logout | Call logout endpoint, verify token invalidated | 200 OK, subsequent requests return 401 |
|
||||||
| TC-API-1.4 | Email+password login (UAT) | POST /api/auth/sign-in/email with uat-super@groombook.dev + SEED_UAT_SUPER_PASSWORD | 200 OK, session cookie returned |
|
|
||||||
| TC-API-1.5 | Email+password login — groomer | POST /api/auth/sign-in/email with uat-groomer@groombook.dev + SEED_UAT_GROOMER_PASSWORD | 200 OK, session cookie returned |
|
|
||||||
| TC-API-1.6 | Email+password login — customer | POST /api/auth/sign-in/email with uat-customer@groombook.dev + SEED_UAT_CUSTOMER_PASSWORD | 200 OK, session cookie returned |
|
|
||||||
| TC-API-1.7 | Email+password login — tester | POST /api/auth/sign-in/email with uat-tester@groombook.dev + SEED_UAT_TESTER_PASSWORD | 200 OK, session cookie returned |
|
|
||||||
| TC-API-1.8 | Email+password — invalid password | POST /api/auth/sign-in/email with wrong password | 400 Bad Request, error returned |
|
|
||||||
| TC-API-1.9 | Email+password — unknown user | POST /api/auth/sign-in/email with non-existent email | 400 Bad Request, error returned |
|
|
||||||
|
|
||||||
### 4.2 Client Management
|
### 4.2 Client Management
|
||||||
|
|
||||||
@@ -183,23 +177,6 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
|
|||||||
| TC-API-14.4 | Update group notes | PATCH /api/appointment-groups/{id} with notes | 200 OK, notes updated |
|
| TC-API-14.4 | Update group notes | PATCH /api/appointment-groups/{id} with notes | 200 OK, notes updated |
|
||||||
| TC-API-14.5 | Cancel group | DELETE /api/appointment-groups/{id} | 200 OK, all appointments cancelled |
|
| TC-API-14.5 | Cancel group | DELETE /api/appointment-groups/{id} | 200 OK, all appointments cancelled |
|
||||||
|
|
||||||
### 4.15 Public Booking Flow (Scheduling Engine Buffer Integration)
|
|
||||||
|
|
||||||
| # | Scenario | Steps | Expected |
|
|
||||||
|---|----------|-------|----------|
|
|
||||||
| TC-API-15.1 | List active services | GET /api/book/services | 200 OK, list of active services with name, price, duration |
|
|
||||||
| TC-API-15.2 | Get availability — missing params | GET /api/book/availability | 400 Bad Request, error indicating required params |
|
|
||||||
| TC-API-15.3 | Get availability — invalid date | GET /api/book/availability?serviceId=uuid&date=invalid | 400 Bad Request, date must be YYYY-MM-DD |
|
|
||||||
| TC-API-15.4 | Get availability — service not found | GET /api/book/availability?serviceId=nonexistent&date=2026-06-01 | 404 Not Found |
|
|
||||||
| TC-API-15.5 | Get availability — valid date/service | GET /api/book/availability?serviceId={serviceId}&date=2026-06-01 | 200 OK, array of ISO startTime strings for available slots |
|
|
||||||
| TC-API-15.6 | Availability excludes booked slots | GET /api/book/availability for date with existing appointments | 200 OK, only slots not overlapping booked appointments |
|
|
||||||
| TC-API-15.7 | Availability respects groomer availability | GET /api/book/availability for date with no groomers | 200 OK, empty array |
|
|
||||||
| TC-API-15.8 | Create booking — missing required fields | POST /api/book/appointments with partial data | 400 Bad Request, validation errors |
|
|
||||||
| TC-API-15.9 | Create booking — invalid pet/client/service | POST /api/book/appointments with nonexistent IDs | 400/404 Bad Request |
|
|
||||||
| TC-API-15.10 | Create booking — valid | POST /api/book/appointments with all required fields | 201 Created, appointment object returned |
|
|
||||||
| TC-API-15.11 | Create booking — saves petSizeCategory | POST /api/book/appointments with petSizeCategory | 201 Created, pet's petSizeCategory updated |
|
|
||||||
| TC-API-15.12 | Create booking — saves petCoatType | POST /api/book/appointments with petCoatType | 201 Created, pet's coatType updated |
|
|
||||||
|
|
||||||
## Pass/Fail Criteria
|
## Pass/Fail Criteria
|
||||||
|
|
||||||
**Pass:**
|
**Pass:**
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||||
import { petsRouter } from "../routes/pets.js";
|
import { petsRouter } from "../routes/pets.js";
|
||||||
import { and, eq, exists, or } from "../db/index.js";
|
|
||||||
|
|
||||||
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
|
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -22,8 +21,8 @@ const MANAGER: StaffRow = {
|
|||||||
|
|
||||||
// ─── Mutable mock state ───────────────────────────────────────────────────────
|
// ─── Mutable mock state ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const CLIENT_ID = "11111111-1111-1111-1111-111111111111";
|
const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
|
||||||
const PET_ID = "22222222-2222-2222-2222-222222222222";
|
const PET_ID = "660e8400-e29b-41d4-a716-446655440002";
|
||||||
|
|
||||||
let petRows: Record<string, unknown>[] = [];
|
let petRows: Record<string, unknown>[] = [];
|
||||||
let appointmentRows: Record<string, unknown>[] = [];
|
let appointmentRows: Record<string, unknown>[] = [];
|
||||||
@@ -146,8 +145,7 @@ function makeDeleteChainable(): unknown {
|
|||||||
return chain;
|
return chain;
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.mock("../db", async (importOriginal) => {
|
vi.mock("../db", () => {
|
||||||
const db = await importOriginal<typeof import("../db/index.js")>();
|
|
||||||
const pets = new Proxy({ _name: "pets" }, { get: (t, p) => p === "_name" ? "pets" : {} });
|
const pets = new Proxy({ _name: "pets" }, { get: (t, p) => p === "_name" ? "pets" : {} });
|
||||||
const appointments = new Proxy({ _name: "appointments" }, { get: (t, p) => p === "_name" ? "appointments" : {} });
|
const appointments = new Proxy({ _name: "appointments" }, { get: (t, p) => p === "_name" ? "appointments" : {} });
|
||||||
return {
|
return {
|
||||||
@@ -165,10 +163,10 @@ vi.mock("../db", async (importOriginal) => {
|
|||||||
}),
|
}),
|
||||||
pets,
|
pets,
|
||||||
appointments,
|
appointments,
|
||||||
and: db.and,
|
and: vi.fn(),
|
||||||
eq: db.eq,
|
eq: vi.fn(),
|
||||||
exists: db.exists,
|
exists: vi.fn(),
|
||||||
or: db.or,
|
or: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ vi.mock("../db", () => {
|
|||||||
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
|
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const impersonationAuditLogs = new Proxy(
|
||||||
|
{ _name: "impersonationAuditLogs" },
|
||||||
|
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
|
||||||
|
);
|
||||||
|
|
||||||
const appointments = new Proxy(
|
const appointments = new Proxy(
|
||||||
{ _name: "appointments" },
|
{ _name: "appointments" },
|
||||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||||
@@ -99,8 +104,12 @@ vi.mock("../db", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
insert: () => ({
|
||||||
|
values: () => ({ returning: () => [{}] }),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
impersonationSessions,
|
impersonationSessions,
|
||||||
|
impersonationAuditLogs,
|
||||||
appointments,
|
appointments,
|
||||||
eq: vi.fn(),
|
eq: vi.fn(),
|
||||||
and: vi.fn(),
|
and: vi.fn(),
|
||||||
|
|||||||
@@ -1,431 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
|
|
||||||
// ─── Test configuration constants (must match seed.ts) ─────────────────────────
|
|
||||||
|
|
||||||
const UAT_ACCOUNTS = [
|
|
||||||
{
|
|
||||||
email: "uat-super@groombook.dev",
|
|
||||||
name: "UAT Super User",
|
|
||||||
passwordEnv: "SEED_UAT_SUPER_PASSWORD",
|
|
||||||
staffEmail: "uat-super@groombook.dev",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
email: "uat-groomer@groombook.dev",
|
|
||||||
name: "UAT Staff Groomer",
|
|
||||||
passwordEnv: "SEED_UAT_GROOMER_PASSWORD",
|
|
||||||
staffEmail: "uat-groomer@groombook.dev",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
email: "uat-customer@groombook.dev",
|
|
||||||
name: "UAT Customer",
|
|
||||||
passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD",
|
|
||||||
staffEmail: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
email: "uat-tester@groombook.dev",
|
|
||||||
name: "UAT Tester",
|
|
||||||
passwordEnv: "SEED_UAT_TESTER_PASSWORD",
|
|
||||||
staffEmail: "uat-tester@groombook.dev",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const TEST_PASSWORD = "test-password-123";
|
|
||||||
|
|
||||||
// ─── Password hashing — must match better-auth/crypto (N=16384, r=16, p=1, dkLen=64, hex) ───
|
|
||||||
|
|
||||||
async function hashPassword(password: string): Promise<string> {
|
|
||||||
const { hashPassword } = await import("better-auth/crypto");
|
|
||||||
return hashPassword(password);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Mock DB state ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface UserRow {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
emailVerified: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AccountRow {
|
|
||||||
id: string;
|
|
||||||
accountId: string;
|
|
||||||
providerId: string;
|
|
||||||
userId: string;
|
|
||||||
password: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StaffRow {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
userId: string | null;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let dbUsers: UserRow[] = [];
|
|
||||||
let dbAccounts: AccountRow[] = [];
|
|
||||||
let dbStaff: StaffRow[] = [];
|
|
||||||
let insertedUsers: UserRow[] = [];
|
|
||||||
let insertedAccounts: AccountRow[] = [];
|
|
||||||
let updatedStaff: Array<{ id: string; userId: string }> = [];
|
|
||||||
|
|
||||||
const originalEnv = { ...process.env };
|
|
||||||
|
|
||||||
function resetMock() {
|
|
||||||
dbUsers = [];
|
|
||||||
dbAccounts = [];
|
|
||||||
dbStaff = [];
|
|
||||||
insertedUsers = [];
|
|
||||||
insertedAccounts = [];
|
|
||||||
updatedStaff = [];
|
|
||||||
process.env = { ...originalEnv };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Mock schema ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function makeSchemaMock() {
|
|
||||||
const user = new Proxy({ _name: "user" }, {
|
|
||||||
get(_t, p) {
|
|
||||||
if (p === "_name") return "user";
|
|
||||||
if (p === "$inferSelect") return {};
|
|
||||||
return { table: "user", column: p };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const account = new Proxy({ _name: "account" }, {
|
|
||||||
get(_t, p) {
|
|
||||||
if (p === "_name") return "account";
|
|
||||||
if (p === "$inferSelect") return {};
|
|
||||||
return { table: "account", column: p };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const staff = new Proxy({ _name: "staff" }, {
|
|
||||||
get(_t, p) {
|
|
||||||
if (p === "_name") return "staff";
|
|
||||||
if (p === "$inferSelect") return {};
|
|
||||||
return { table: "staff", column: p };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { user, account, staff };
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user: mockUser, account: mockAccount, staff: mockStaff } = makeSchemaMock();
|
|
||||||
|
|
||||||
function eq(col: unknown, val: unknown) {
|
|
||||||
return { __type: "eq" as const, col, val };
|
|
||||||
}
|
|
||||||
|
|
||||||
function and(...conds: unknown[]) {
|
|
||||||
return { __type: "and" as const, conds };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Seed logic helper ─────────────────────────────────────────────────────────
|
|
||||||
// Inline the credential provisioning logic under test so we can call it directly.
|
|
||||||
// This is the same logic as seed.ts lines 514-598.
|
|
||||||
|
|
||||||
interface SeedAccount {
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
passwordEnv: string;
|
|
||||||
staffEmail: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let uuidCounter = 0;
|
|
||||||
function mockUuid(): string {
|
|
||||||
return `mock-uuid-${++uuidCounter}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedUatCredentials(
|
|
||||||
accounts: SeedAccount[],
|
|
||||||
opts: {
|
|
||||||
users?: UserRow[];
|
|
||||||
accounts?: AccountRow[];
|
|
||||||
staff?: StaffRow[];
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const { users = dbUsers, accounts: accts = dbAccounts, staff: staffRows = dbStaff } = opts;
|
|
||||||
|
|
||||||
for (const acct of accounts) {
|
|
||||||
const password = process.env[acct.passwordEnv];
|
|
||||||
if (!password) {
|
|
||||||
console.warn(`⚠ Skipping ${acct.email} — ${acct.passwordEnv} not set`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Find or create the Better-Auth user
|
|
||||||
const existingUser = users.find((u) => u.email === acct.email);
|
|
||||||
|
|
||||||
let userId: string;
|
|
||||||
if (existingUser) {
|
|
||||||
userId = existingUser.id;
|
|
||||||
} else {
|
|
||||||
userId = mockUuid();
|
|
||||||
const newUser: UserRow = { id: userId, name: acct.name, email: acct.email, emailVerified: true };
|
|
||||||
insertedUsers.push(newUser);
|
|
||||||
dbUsers.push(newUser);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check if credential account already exists
|
|
||||||
const existingAccount = accts.find(
|
|
||||||
(a) => a.userId === userId && a.providerId === "credential"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingAccount) {
|
|
||||||
// skip — already has credential account
|
|
||||||
} else {
|
|
||||||
// Use Better-Auth's hashPassword so test helper matches production seed.ts
|
|
||||||
const { hashPassword } = await import("better-auth/crypto");
|
|
||||||
const passwordHash = await hashPassword(password);
|
|
||||||
|
|
||||||
const newAccount: AccountRow = {
|
|
||||||
id: mockUuid(),
|
|
||||||
accountId: userId,
|
|
||||||
providerId: "credential",
|
|
||||||
userId,
|
|
||||||
password: passwordHash,
|
|
||||||
};
|
|
||||||
insertedAccounts.push(newAccount);
|
|
||||||
dbAccounts.push(newAccount);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Link staff record to Better-Auth user
|
|
||||||
if (acct.staffEmail) {
|
|
||||||
const existingStaff = staffRows.find((s) => s.email === acct.staffEmail);
|
|
||||||
if (existingStaff && !existingStaff.userId) {
|
|
||||||
existingStaff.userId = userId;
|
|
||||||
updatedStaff.push({ id: existingStaff.id, userId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("seedUatCredentials — credential provisioning logic", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
resetMock();
|
|
||||||
uuidCounter = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env = { ...originalEnv };
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── AC-1: creates user + account when neither exists ──────────────────────
|
|
||||||
|
|
||||||
it("AC-1: creates user and account for each UAT account with password env var set", async () => {
|
|
||||||
process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD;
|
|
||||||
process.env.SEED_UAT_GROOMER_PASSWORD = TEST_PASSWORD;
|
|
||||||
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
|
|
||||||
process.env.SEED_UAT_TESTER_PASSWORD = TEST_PASSWORD;
|
|
||||||
|
|
||||||
await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] });
|
|
||||||
|
|
||||||
// 4 users created (customer + tester have no staff, super + groomer do)
|
|
||||||
expect(insertedUsers).toHaveLength(4);
|
|
||||||
expect(insertedUsers.find((u) => u.email === "uat-super@groombook.dev")).toBeDefined();
|
|
||||||
expect(insertedUsers.find((u) => u.email === "uat-groomer@groombook.dev")).toBeDefined();
|
|
||||||
expect(insertedUsers.find((u) => u.email === "uat-customer@groombook.dev")).toBeDefined();
|
|
||||||
expect(insertedUsers.find((u) => u.email === "uat-tester@groombook.dev")).toBeDefined();
|
|
||||||
|
|
||||||
// 4 accounts created
|
|
||||||
expect(insertedAccounts).toHaveLength(4);
|
|
||||||
for (const acct of insertedAccounts) {
|
|
||||||
expect(acct.providerId).toBe("credential");
|
|
||||||
// Better-Auth uses hex encoding: saltHex:keyHex (both lowercase hex)
|
|
||||||
expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
|
|
||||||
// Verify the hash is scrypt with correct params (N=16384, r=16, p=1, dkLen=64)
|
|
||||||
const parts = acct.password!.split(":");
|
|
||||||
const saltHex = parts[0]!;
|
|
||||||
const keyHex = parts[1]!;
|
|
||||||
const salt = Buffer.from(saltHex, "hex");
|
|
||||||
const storedHash = Buffer.from(keyHex, "hex");
|
|
||||||
expect(salt).toHaveLength(16);
|
|
||||||
expect(storedHash).toHaveLength(64);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── AC-2: emailVerified = true ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
it("AC-2: created users have emailVerified = true", async () => {
|
|
||||||
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
|
|
||||||
|
|
||||||
await seedUatCredentials(
|
|
||||||
[UAT_ACCOUNTS[2]!], // customer only
|
|
||||||
{ users: [], accounts: [], staff: [] }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(insertedUsers[0]!.emailVerified).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── AC-3: providerId = credential, password is hashed ──────────────────────
|
|
||||||
|
|
||||||
it("AC-3: account records use providerId='credential' with properly formatted hashed password", async () => {
|
|
||||||
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
|
|
||||||
|
|
||||||
await seedUatCredentials(
|
|
||||||
[UAT_ACCOUNTS[2]!],
|
|
||||||
{ users: [], accounts: [], staff: [] }
|
|
||||||
);
|
|
||||||
|
|
||||||
const acct = insertedAccounts[0]!;
|
|
||||||
expect(acct.providerId).toBe("credential");
|
|
||||||
// Better-Auth uses hex: saltHex (32 chars) : keyHex (128 chars)
|
|
||||||
expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
|
|
||||||
const parts = acct.password!.split(":");
|
|
||||||
const saltHex = parts[0]!;
|
|
||||||
const keyHex = parts[1]!;
|
|
||||||
expect(() => Buffer.from(saltHex, "hex")).not.toThrow();
|
|
||||||
expect(() => Buffer.from(keyHex, "hex")).not.toThrow();
|
|
||||||
const salt = Buffer.from(saltHex, "hex");
|
|
||||||
const storedHash = Buffer.from(keyHex, "hex");
|
|
||||||
expect(salt).toHaveLength(16);
|
|
||||||
expect(storedHash).toHaveLength(64);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── AC-4: staff.userId is linked ────────────────────────────────────────────
|
|
||||||
|
|
||||||
it("AC-4: links staff.userId to the Better-Auth user when staff record exists", async () => {
|
|
||||||
process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD;
|
|
||||||
const staffRows: StaffRow[] = [
|
|
||||||
{ id: "staff-super-1", email: "uat-super@groombook.dev", userId: null, name: "UAT Super User" },
|
|
||||||
];
|
|
||||||
|
|
||||||
await seedUatCredentials([UAT_ACCOUNTS[0]!], { users: [], accounts: [], staff: staffRows });
|
|
||||||
|
|
||||||
expect(updatedStaff).toHaveLength(1);
|
|
||||||
expect(updatedStaff[0]!.id).toBe("staff-super-1");
|
|
||||||
expect(updatedStaff[0]!.userId).toBe("mock-uuid-1");
|
|
||||||
expect(staffRows[0]!.userId).toBe("mock-uuid-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("AC-4b: does not update staff.userId if already set", async () => {
|
|
||||||
process.env.SEED_UAT_GROOMER_PASSWORD = TEST_PASSWORD;
|
|
||||||
const staffRows: StaffRow[] = [
|
|
||||||
{ id: "staff-groomer-1", email: "uat-groomer@groombook.dev", userId: "already-linked", name: "UAT Groomer" },
|
|
||||||
];
|
|
||||||
|
|
||||||
await seedUatCredentials([UAT_ACCOUNTS[1]!], { users: [], accounts: [], staff: staffRows });
|
|
||||||
|
|
||||||
expect(updatedStaff).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── AC-5: idempotent — skips when user already exists ───────────────────────
|
|
||||||
|
|
||||||
it("AC-5: re-running does not duplicate user or account records (idempotent)", async () => {
|
|
||||||
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
|
|
||||||
|
|
||||||
const preExistingUsers: UserRow[] = [
|
|
||||||
{ id: "pre-existing-user", email: "uat-customer@groombook.dev", name: "UAT Customer", emailVerified: true },
|
|
||||||
];
|
|
||||||
const preExistingAccounts: AccountRow[] = [
|
|
||||||
{
|
|
||||||
id: "pre-existing-acct",
|
|
||||||
accountId: "pre-existing-user",
|
|
||||||
providerId: "credential",
|
|
||||||
userId: "pre-existing-user",
|
|
||||||
password: await hashPassword(TEST_PASSWORD),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// First call — nothing inserted (user + account pre-exist)
|
|
||||||
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
|
|
||||||
users: preExistingUsers,
|
|
||||||
accounts: preExistingAccounts,
|
|
||||||
staff: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(insertedUsers).toHaveLength(0);
|
|
||||||
expect(insertedAccounts).toHaveLength(0);
|
|
||||||
|
|
||||||
// Second call — still nothing inserted
|
|
||||||
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
|
|
||||||
users: preExistingUsers,
|
|
||||||
accounts: preExistingAccounts,
|
|
||||||
staff: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(insertedUsers).toHaveLength(0);
|
|
||||||
expect(insertedAccounts).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── AC-6: missing env var skips with warning ────────────────────────────────
|
|
||||||
|
|
||||||
it("AC-6: missing SEED_UAT_*_PASSWORD env var skips that account (no error)", async () => {
|
|
||||||
// No env vars set at all
|
|
||||||
delete process.env.SEED_UAT_SUPER_PASSWORD;
|
|
||||||
delete process.env.SEED_UAT_GROOMER_PASSWORD;
|
|
||||||
delete process.env.SEED_UAT_CUSTOMER_PASSWORD;
|
|
||||||
delete process.env.SEED_UAT_TESTER_PASSWORD;
|
|
||||||
|
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined);
|
|
||||||
|
|
||||||
await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] });
|
|
||||||
|
|
||||||
// Nothing created
|
|
||||||
expect(insertedUsers).toHaveLength(0);
|
|
||||||
expect(insertedAccounts).toHaveLength(0);
|
|
||||||
// Warning logged for each of the 4 accounts
|
|
||||||
expect(warnSpy).toHaveBeenCalledTimes(4);
|
|
||||||
expect(warnSpy).toHaveBeenCalledWith(
|
|
||||||
"⚠ Skipping uat-super@groombook.dev — SEED_UAT_SUPER_PASSWORD not set"
|
|
||||||
);
|
|
||||||
|
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── AC-7: partial env var coverage ─────────────────────────────────────────
|
|
||||||
|
|
||||||
it("AC-7: only accounts with password env var set are provisioned", async () => {
|
|
||||||
process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD;
|
|
||||||
// Only super has password set
|
|
||||||
|
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined);
|
|
||||||
|
|
||||||
await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] });
|
|
||||||
|
|
||||||
expect(insertedUsers).toHaveLength(1);
|
|
||||||
expect(insertedUsers[0]!.email).toBe("uat-super@groombook.dev");
|
|
||||||
expect(insertedAccounts).toHaveLength(1);
|
|
||||||
expect(insertedAccounts[0]!.accountId).toBe("mock-uuid-1");
|
|
||||||
|
|
||||||
// 3 warnings for missing accounts
|
|
||||||
expect(warnSpy).toHaveBeenCalledTimes(3);
|
|
||||||
|
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Password hash format verification ───────────────────────────────────────
|
|
||||||
|
|
||||||
describe("password hash format — scrypt parameters", () => {
|
|
||||||
it("hashes use salt:hash format with 16-byte salt and 64-byte output", async () => {
|
|
||||||
const hash = await hashPassword("test-password");
|
|
||||||
const parts = hash.split(":");
|
|
||||||
const saltHex = parts[0]!;
|
|
||||||
const keyHex = parts[1]!;
|
|
||||||
|
|
||||||
expect(hash).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
|
|
||||||
expect(Buffer.from(saltHex, "hex")).toHaveLength(16);
|
|
||||||
expect(Buffer.from(keyHex, "hex")).toHaveLength(64);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("same password produces different hashes (due to random salt)", async () => {
|
|
||||||
const hash1 = await hashPassword("same-password");
|
|
||||||
const hash2 = await hashPassword("same-password");
|
|
||||||
|
|
||||||
expect(hash1).not.toBe(hash2);
|
|
||||||
// Both are valid Better-Auth hex format
|
|
||||||
expect(hash1).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
|
|
||||||
expect(hash2).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("different passwords produce different hashes", async () => {
|
|
||||||
const hash1 = await hashPassword("password1");
|
|
||||||
const hash2 = await hashPassword("password2");
|
|
||||||
|
|
||||||
expect(hash1).not.toBe(hash2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+1
-85
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import { eq, and, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import * as schema from "./schema.js";
|
import * as schema from "./schema.js";
|
||||||
|
|
||||||
// ── Seed profile configuration ─────────────────────────────────────────────
|
// ── Seed profile configuration ─────────────────────────────────────────────
|
||||||
@@ -511,90 +511,6 @@ async function seedKnownUsers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Better-Auth email+password credentials for UAT accounts ──────────────────
|
|
||||||
// Provisions Better-Auth user + account records so UAT testers can log in
|
|
||||||
// via email+password (POST /api/auth/sign-in/email) instead of Authentik SSO.
|
|
||||||
const uatPasswordAccounts = [
|
|
||||||
{ email: "uat-super@groombook.dev", name: "UAT Super User", passwordEnv: "SEED_UAT_SUPER_PASSWORD", staffEmail: "uat-super@groombook.dev" },
|
|
||||||
{ email: "uat-groomer@groombook.dev", name: "UAT Staff Groomer", passwordEnv: "SEED_UAT_GROOMER_PASSWORD", staffEmail: "uat-groomer@groombook.dev" },
|
|
||||||
{ email: "uat-customer@groombook.dev", name: "UAT Customer", passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD", staffEmail: null },
|
|
||||||
{ email: "uat-tester@groombook.dev", name: "UAT Tester", passwordEnv: "SEED_UAT_TESTER_PASSWORD", staffEmail: "uat-tester@groombook.dev" },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const acct of uatPasswordAccounts) {
|
|
||||||
const password = process.env[acct.passwordEnv];
|
|
||||||
if (!password) {
|
|
||||||
console.warn(`⚠ Skipping ${acct.email} — ${acct.passwordEnv} not set`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Find or create the Better-Auth user
|
|
||||||
const [existingUser] = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.user)
|
|
||||||
.where(eq(schema.user.email, acct.email))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let userId: string;
|
|
||||||
if (existingUser) {
|
|
||||||
userId = existingUser.id;
|
|
||||||
console.log(`✓ Better-Auth user '${acct.name}' already exists — skipping user creation`);
|
|
||||||
} else {
|
|
||||||
userId = uuid();
|
|
||||||
await db.insert(schema.user).values({
|
|
||||||
id: userId,
|
|
||||||
name: acct.name,
|
|
||||||
email: acct.email,
|
|
||||||
emailVerified: true,
|
|
||||||
});
|
|
||||||
console.log(`✓ Created Better-Auth user '${acct.name}' (${acct.email})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check if credential account already exists
|
|
||||||
const [existingAccount] = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.account)
|
|
||||||
.where(and(
|
|
||||||
eq(schema.account.userId, userId),
|
|
||||||
eq(schema.account.providerId, "credential")
|
|
||||||
))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existingAccount) {
|
|
||||||
console.log(`✓ Credential account for '${acct.email}' already exists — skipping`);
|
|
||||||
} else {
|
|
||||||
// Use Better-Auth's own hashPassword to guarantee parameter/encoding match.
|
|
||||||
// better-auth/crypto uses: N=16384, r=16, p=1, dkLen=64, salt as 16-byte random
|
|
||||||
// hex string, key hex-encoded, format saltHex:keyHex.
|
|
||||||
const { hashPassword } = await import("better-auth/crypto");
|
|
||||||
const passwordHash = await hashPassword(password);
|
|
||||||
|
|
||||||
await db.insert(schema.account).values({
|
|
||||||
id: uuid(),
|
|
||||||
accountId: userId,
|
|
||||||
providerId: "credential",
|
|
||||||
userId,
|
|
||||||
password: passwordHash,
|
|
||||||
});
|
|
||||||
console.log(`✓ Created credential account for '${acct.email}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Link staff record to Better-Auth user (for accounts that have staff records)
|
|
||||||
if (acct.staffEmail) {
|
|
||||||
const [existingStaff] = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.staff)
|
|
||||||
.where(eq(schema.staff.email, acct.staffEmail))
|
|
||||||
.limit(1);
|
|
||||||
if (existingStaff && !existingStaff.userId) {
|
|
||||||
await db.update(schema.staff)
|
|
||||||
.set({ userId })
|
|
||||||
.where(eq(schema.staff.id, existingStaff.id));
|
|
||||||
console.log(`✓ Linked staff '${acct.staffEmail}' → Better-Auth user`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
||||||
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
||||||
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
||||||
|
|||||||
@@ -3,5 +3,6 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@9.15.4",
|
||||||
"license": "AGPL-3.0-only"
|
"license": "AGPL-3.0-only"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
||||||
"extends": ["config:recommended", ":pinAllExceptPeerDependencies", "helpers:pinGitHubActionDigests"],
|
|
||||||
"labels": ["dependencies"],
|
|
||||||
"prConcurrentLimit": 5,
|
|
||||||
"packageRules": [
|
|
||||||
{"matchUpdateTypes": ["minor", "patch"], "groupName": "minor and patch dependencies", "automerge": false},
|
|
||||||
{"matchDepTypes": ["devDependencies"], "matchUpdateTypes": ["minor", "patch"], "automerge": true, "automergeType": "pr"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user