Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/labeler.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
name: PR Labeler
on:
pull_request_target:

permissions:
contents: read
pull-requests: write
issues: write

jobs:
label:
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions e2e/dashboard-widgets.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,12 +174,13 @@
});
});
});

test("dashboard widgets render with mocked metrics", async ({ page }) => {
await page.goto("/dashboard", { waitUntil: "load" });
await expect(page.getByRole("heading", { name: /dashboard/i })).toBeVisible({ timeout: 30000 });
await expect(page.getByRole("heading", { name: "Your Commits" })).toBeVisible({ timeout: 10000 });
await expect(page.getByRole("heading", { name: "PR Analytics" })).toBeVisible({ timeout: 10000 });
await expect(page.getByRole("heading", { name: "Goals" })).toBeVisible({ timeout: 10000 });

Check failure on line 183 in e2e/dashboard-widgets.spec.js

View workflow job for this annotation

GitHub Actions / Playwright smoke tests

[chromium] › e2e/dashboard-widgets.spec.js:178:5 › dashboard widgets render with mocked metrics

1) [chromium] › e2e/dashboard-widgets.spec.js:178:5 › dashboard widgets render with mocked metrics Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: getByRole('heading', { name: 'Goals' }) Expected: visible Error: strict mode violation: getByRole('heading', { name: 'Goals' }) resolved to 2 elements: 1) <h2 class="text-2xl font-bold tracking-tight">Goals & Insights</h2> aka getByRole('heading', { name: 'Goals & Insights' }) 2) <h2 class="text-lg font-semibold text-[var(--card-foreground)]">Goals</h2> aka getByRole('heading', { name: 'Goals', exact: true }) Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for getByRole('heading', { name: 'Goals' }) 181 | await expect(page.getByRole("heading", { name: "Your Commits" })).toBeVisible({ timeout: 10000 }); 182 | await expect(page.getByRole("heading", { name: "PR Analytics" })).toBeVisible({ timeout: 10000 }); > 183 | await expect(page.getByRole("heading", { name: "Goals" })).toBeVisible({ timeout: 10000 }); | ^ 184 | await expect(page.getByText("Make 10 commits")).toBeVisible({ timeout: 10000 }); 185 | }); 186 | at /home/runner/work/devtrack/devtrack/e2e/dashboard-widgets.spec.js:183:62

Check failure on line 183 in e2e/dashboard-widgets.spec.js

View workflow job for this annotation

GitHub Actions / Playwright smoke tests

[chromium] › e2e/dashboard-widgets.spec.js:178:5 › dashboard widgets render with mocked metrics

1) [chromium] › e2e/dashboard-widgets.spec.js:178:5 › dashboard widgets render with mocked metrics Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: getByRole('heading', { name: 'Goals' }) Expected: visible Error: strict mode violation: getByRole('heading', { name: 'Goals' }) resolved to 2 elements: 1) <h2 class="text-2xl font-bold tracking-tight">Goals & Insights</h2> aka getByRole('heading', { name: 'Goals & Insights' }) 2) <h2 class="text-lg font-semibold text-[var(--card-foreground)]">Goals</h2> aka getByRole('heading', { name: 'Goals', exact: true }) Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for getByRole('heading', { name: 'Goals' }) 181 | await expect(page.getByRole("heading", { name: "Your Commits" })).toBeVisible({ timeout: 10000 }); 182 | await expect(page.getByRole("heading", { name: "PR Analytics" })).toBeVisible({ timeout: 10000 }); > 183 | await expect(page.getByRole("heading", { name: "Goals" })).toBeVisible({ timeout: 10000 }); | ^ 184 | await expect(page.getByText("Make 10 commits")).toBeVisible({ timeout: 10000 }); 185 | }); 186 | at /home/runner/work/devtrack/devtrack/e2e/dashboard-widgets.spec.js:183:62
await expect(page.getByText("Make 10 commits")).toBeVisible({ timeout: 10000 });
});

Expand Down
28 changes: 26 additions & 2 deletions src/app/api/goals/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { dispatchToAllWebhooks } from "@/lib/webhooks";

export const dynamic = "force-dynamic";

const AUTO_SYNCED_GOAL_UNITS = new Set(["commits", "prs"]);

export async function PATCH(
req: Request,
{ params }: { params: { id: string } }
Expand All @@ -19,9 +21,13 @@ export async function PATCH(
if (!user) return Response.json({ error: "User not found" }, { status: 404 });

const body = await req.json().catch(() => ({}));
const { current } = body;
const { current, unit } = body;

if (typeof current !== "number" || current < 0) {
if (
typeof current !== "number" ||
!Number.isInteger(current) ||
current < 0
) {
return Response.json(
{ error: "Invalid current value" },
{ status: 400 }
Expand All @@ -39,6 +45,24 @@ export async function PATCH(
return Response.json({ error: "Goal not found" }, { status: 404 });
}

const requestedUnit = typeof unit === "string" ? unit : existingGoal.unit;
if (
AUTO_SYNCED_GOAL_UNITS.has(existingGoal.unit) ||
AUTO_SYNCED_GOAL_UNITS.has(requestedUnit)
) {
return Response.json(
{ error: "current for GitHub-synced goals can only be updated by sync" },
{ status: 400 }
);
}

if (current > existingGoal.target) {
return Response.json(
{ error: "current cannot exceed target" },
{ status: 400 }
);
}

const wasCompleted = existingGoal.current >= existingGoal.target;
const { data: updatedGoal, error } = await supabaseAdmin
.from("goals")
Expand Down
116 changes: 116 additions & 0 deletions test/goals-patch-progress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
getServerSession: vi.fn(),
resolveAppUser: vi.fn(),
from: vi.fn(),
update: vi.fn(),
existingGoal: {
id: "goal-1",
user_id: "user-1",
title: "Ship work",
target: 10,
current: 2,
unit: "tasks",
},
}));

vi.mock("next-auth", () => ({
getServerSession: mocks.getServerSession,
}));

vi.mock("@/lib/auth", () => ({
authOptions: {},
}));

vi.mock("@/lib/resolve-user", () => ({
resolveAppUser: mocks.resolveAppUser,
}));

vi.mock("@/lib/supabase", () => ({
supabaseAdmin: {
from: mocks.from,
},
}));

import { PATCH } from "@/app/api/goals/[id]/route";

function createSupabaseBuilder() {
const builder = {
select: vi.fn(() => builder),
eq: vi.fn(() => builder),
update: mocks.update.mockImplementation(() => builder),
maybeSingle: vi.fn(async () => ({ data: mocks.existingGoal, error: null })),
single: vi.fn(async () => ({
data: { ...mocks.existingGoal, current: 4 },
error: null,
})),
};

return builder;
}

async function patchGoal(body: Record<string, unknown>) {
const req = new Request("http://localhost/api/goals/goal-1", {
method: "PATCH",
body: JSON.stringify(body),
});

return PATCH(req, { params: { id: "goal-1" } });
}

describe("PATCH /api/goals/[id] progress updates", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getServerSession.mockResolvedValue({
githubId: "github-1",
githubLogin: "octocat",
});
mocks.resolveAppUser.mockResolvedValue({ id: "user-1" });
mocks.from.mockImplementation(() => createSupabaseBuilder());
mocks.existingGoal = {
id: "goal-1",
user_id: "user-1",
title: "Ship work",
target: 10,
current: 2,
unit: "tasks",
};
});

it("allows manual progress updates for non-GitHub goals", async () => {
const response = await patchGoal({ current: 4 });
const json = await response.json();

expect(response.status).toBe(200);
expect(json.goal.current).toBe(4);
expect(mocks.update).toHaveBeenCalledWith({ current: 4 });
});

it.each(["commits", "prs"])(
"rejects client-supplied progress for %s goals",
async (unit) => {
mocks.existingGoal = { ...mocks.existingGoal, unit };

const response = await patchGoal({ current: 9 });
const json = await response.json();

expect(response.status).toBe(400);
expect(json.error).toBe(
"current for GitHub-synced goals can only be updated by sync"
);
expect(mocks.update).not.toHaveBeenCalled();
}
);

it("rejects setting progress while converting a goal to a GitHub-synced unit", async () => {
const response = await patchGoal({ unit: "commits", current: 9 });
const json = await response.json();

expect(response.status).toBe(400);
expect(json.error).toBe(
"current for GitHub-synced goals can only be updated by sync"
);
expect(mocks.update).not.toHaveBeenCalled();
});
});
Loading