Files
webstudio/apps/builder/app/shared/db/user.server.test.ts
Oleg Isonen 9963c95ef8 fix: Sync OAuth profile fields on login (#6376)
Closes #6375

## Summary

- Refresh username, avatar, and provider fields when an existing user
logs in through OAuth.
- Avoid unnecessary database writes when profile fields are unchanged.
- Preserve existing development-login behavior.
- Add regression coverage for changed GitHub profile data.

## Testing

- `pnpm --filter @webstudio-is/builder exec vitest run --project
core-server app/shared/db/user.server.test.ts`
- `pnpm --filter @webstudio-is/builder typecheck`
2026-09-14 12:29:10 +02:00

232 lines
6.6 KiB
TypeScript

import { describe, test, expect } from "vitest";
import {
createTestServer,
db,
testContext,
json,
} from "@webstudio-is/postgrest/testing";
import type { AppContext } from "@webstudio-is/trpc-interface/index.server";
import { AuthorizationError } from "@webstudio-is/trpc-interface/index.server";
import {
getUserById,
createOrLoginWithDev,
createOrLoginWithOAuth,
updateUserProjectsTags,
} from "./user.server";
const server = createTestServer();
const createContext = (overrides: Partial<AppContext> = {}): AppContext =>
({
...testContext,
authorization: { type: "user", userId: "user-1" },
...overrides,
}) as unknown as AppContext;
const userRow = {
id: "user-1",
email: "test@example.com",
username: "tester",
image: "",
provider: "dev",
projectsTags: [],
createdAt: new Date().toISOString(),
team: null,
};
// ─── getUserById ───────────────────────────────────────────────
describe("getUserById (msw)", () => {
test("returns user when found", async () => {
server.use(db.get("User", () => json(userRow)));
const result = await getUserById(createContext(), "user-1");
expect(result.id).toBe("user-1");
expect(result.email).toBe("test@example.com");
expect(result.projectsTags).toEqual([]);
});
test("throws when user not found", async () => {
server.use(
db.get("User", () =>
json(
{ code: "PGRST116", message: "Row not found", details: "", hint: "" },
{ status: 406 }
)
)
);
await expect(getUserById(createContext(), "missing")).rejects.toThrow(
"User not found"
);
});
});
// ─── createOrLoginWithDev ──────────────────────────────────────
describe("createOrLoginWithDev (msw)", () => {
test("returns existing user when found", async () => {
let workspaceChecked = false;
server.use(
// User SELECT resolves successfully → existing user
db.get("User", () => json(userRow)),
// Workspace check — has default workspace
db.get("Workspace", () => {
workspaceChecked = true;
return json({ id: "ws-1" });
})
);
const result = await createOrLoginWithDev(
createContext(),
"test@example.com"
);
expect(result.id).toBe("user-1");
expect(workspaceChecked).toBe(true);
});
test("lazily creates workspace when existing user has none", async () => {
let workspaceCreated = false;
server.use(
db.get("User", () => json(userRow)),
// maybeSingle returns null when no row exists
db.get("Workspace", () => json(null)),
db.post("Workspace", () => {
workspaceCreated = true;
return json({ id: "ws-new" }, { status: 201 });
})
);
const result = await createOrLoginWithDev(
createContext(),
"test@example.com"
);
expect(result.id).toBe("user-1");
expect(workspaceCreated).toBe(true);
});
test("creates new user and workspace when email not found", async () => {
let userInserted = false;
let workspaceInserted = false;
server.use(
// User SELECT → PGRST116 (not found)
db.get("User", () =>
json(
{ code: "PGRST116", message: "Row not found", details: "", hint: "" },
{ status: 406 }
)
),
db.post("User", () => {
userInserted = true;
return json({ ...userRow, id: "new-user" }, { status: 201 });
}),
db.post("Workspace", () => {
workspaceInserted = true;
return json({ id: "ws-new" }, { status: 201 });
})
);
const result = await createOrLoginWithDev(
createContext(),
"new@example.com"
);
expect(result.email).toBe("test@example.com"); // from userRow mock
expect(userInserted).toBe(true);
expect(workspaceInserted).toBe(true);
});
test("throws when User SELECT fails with unexpected error", async () => {
server.use(
db.get("User", () =>
json(
{ code: "PGRST999", message: "DB error", details: "", hint: "" },
{ status: 500 }
)
)
);
await expect(
createOrLoginWithDev(createContext(), "fail@example.com")
).rejects.toThrow("User not found");
});
});
// ─── createOrLoginWithOAuth ───────────────────────────────────
describe("createOrLoginWithOAuth (msw)", () => {
test("syncs changed profile fields for an existing user", async () => {
const updatedUser = {
...userRow,
username: "new-github-username",
image: "https://avatars.example.com/new",
provider: "github",
};
let capturedBody: unknown;
server.use(
db.get("User", () => json(userRow)),
db.patch("User", async ({ request }) => {
capturedBody = await request.json();
return json(updatedUser);
}),
db.get("Workspace", () => json({ id: "ws-1" }))
);
const result = await createOrLoginWithOAuth(createContext(), {
provider: "github",
displayName: "new-github-username",
emails: [{ value: "test@example.com" }],
photos: [{ value: "https://avatars.example.com/new" }],
} as Parameters<typeof createOrLoginWithOAuth>[1]);
expect(capturedBody).toEqual({
username: "new-github-username",
image: "https://avatars.example.com/new",
provider: "github",
});
expect(result.username).toBe("new-github-username");
});
});
// ─── updateUserProjectsTags ───────────────────────────────────
describe("updateUserProjectsTags (msw)", () => {
const tags = [{ id: "tag-1", label: "Design" }];
test("returns updated tags", async () => {
server.use(
db.patch("User", () =>
json({ ...userRow, projectsTags: tags }, { status: 200 })
)
);
const result = await updateUserProjectsTags({ tags }, createContext());
expect(result).toEqual(tags);
});
test("throws AuthorizationError for non-user context", async () => {
await expect(
updateUserProjectsTags(
{ tags },
createContext({ authorization: { type: "anonymous" } })
)
).rejects.toThrow(AuthorizationError);
});
test("throws when DB update fails", async () => {
server.use(
db.patch("User", () =>
json(
{ code: "PGRST123", message: "Update failed", details: "", hint: "" },
{ status: 500 }
)
)
);
await expect(
updateUserProjectsTags({ tags }, createContext())
).rejects.toBeTruthy();
});
});