Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"version": "1.0.1",
"type": "module",
"scripts": {
"dev": "NODE_ENV=development dotenv -- bun --hot src/index.ts --port 3001",
"dev": "NODE_ENV=development dotenv -- bun --hot src/index.ts",
"test": "TZ=UTC bunx --bun vitest run",
"test:integration": "TZ=UTC bunx --bun vitest run --config vitest.integration.config.ts",
"test:watch": "TZ=UTC bunx --bun vitest"
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ registerShutdownHooks();

export default {
fetch: app.fetch,
port: Number.parseInt(apiEnv.PORT, 10),
// In development env validation is skipped, so zod defaults never apply and
// apiEnv.PORT can be undefined — fall back explicitly or Bun binds a random port.
port: Number.parseInt(apiEnv.PORT ?? "3001", 10) || 3001,
idleTimeout: BUN_IDLE_TIMEOUT_SECONDS,
};
128 changes: 127 additions & 1 deletion apps/api/src/integration/profile-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import "@databuddy/test/env";

import { profileAliases, profiles } from "@databuddy/db/schema";
import {
profileAliases,
profiles,
profileTraitChanges,
} from "@databuddy/db/schema";
import { eq } from "@databuddy/db";
import { appRouter, type Context } from "@databuddy/rpc";
import { splitTraits, upsertProfile } from "@databuddy/services/identity";
import {
addToOrganization,
cleanup,
Expand Down Expand Up @@ -120,3 +126,123 @@ describe("profiles.get", () => {
expect(result).toBeNull();
});
});

describe("profiles.getHistory", () => {
iit("returns trait changes newest first", async () => {
const user = await signUp();
const org = await insertOrganization();
await addToOrganization(user.id, org.id, "member");
const website = await insertWebsite({ organizationId: org.id });
await seedProfile(website.id, "user_1");
await db()
.insert(profileTraitChanges)
.values([
{
id: "change_old",
websiteId: website.id,
profileId: "user_1",
traits: { plan: "free" },
changes: { plan: { old: null, new: "free" } },
source: "identify",
createdAt: new Date("2026-01-01T00:00:00Z"),
},
{
id: "change_new",
websiteId: website.id,
profileId: "user_1",
traits: { plan: "pro" },
changes: { plan: { old: "free", new: "pro" } },
source: "billing",
createdAt: new Date("2026-02-01T00:00:00Z"),
},
]);

const result = await call(appRouter.profiles.getHistory, {
...userContext(user, org.id),
})({ websiteId: website.id, profileId: "user_1" });

expect(result.map((r) => r.source)).toEqual(["billing", "identify"]);
expect(result[0]?.changes).toEqual({ plan: { old: "free", new: "pro" } });
});

iit("does not leak history from another organization's website", async () => {
const outsider = await signUp();
const outsiderOrg = await insertOrganization();
await addToOrganization(outsider.id, outsiderOrg.id, "member");

const org = await insertOrganization();
const website = await insertWebsite({ organizationId: org.id });

await expectCode(
call(appRouter.profiles.getHistory, {
...userContext(outsider, outsiderOrg.id),
})({ websiteId: website.id, profileId: "user_1" }),
"FORBIDDEN"
);
});
});

describe("upsertProfile trait history", () => {
iit("records baseline on first identify, diff on the next", async () => {
const org = await insertOrganization();
const website = await insertWebsite({ organizationId: org.id });

await upsertProfile(website.id, "user_1", splitTraits({ plan: "free" }));
await upsertProfile(
website.id,
"user_1",
splitTraits({ plan: "pro" }),
"billing"
);

const rows = await db()
.select()
.from(profileTraitChanges)
.where(eq(profileTraitChanges.profileId, "user_1"))
.orderBy(profileTraitChanges.createdAt);

expect(rows).toHaveLength(2);
expect(rows[0]?.changes).toEqual({ plan: { old: null, new: "free" } });
expect(rows[0]?.source).toBe("identify");
expect(rows[1]?.changes).toEqual({ plan: { old: "free", new: "pro" } });
expect(rows[1]?.source).toBe("billing");
expect(rows[1]?.traits).toEqual({ plan: "pro" });
});

iit("writes no history row when traits are unchanged", async () => {
const org = await insertOrganization();
const website = await insertWebsite({ organizationId: org.id });

await upsertProfile(website.id, "user_1", splitTraits({ plan: "pro" }));
await upsertProfile(website.id, "user_1", splitTraits({ plan: "pro" }));

const rows = await db()
.select()
.from(profileTraitChanges)
.where(eq(profileTraitChanges.profileId, "user_1"));

expect(rows).toHaveLength(1);
});

iit("cascades history deletion when the profile is deleted", async () => {
const org = await insertOrganization();
const website = await insertWebsite({ organizationId: org.id });

await upsertProfile(website.id, "user_1", splitTraits({ plan: "pro" }));
expect(
await db()
.select()
.from(profileTraitChanges)
.where(eq(profileTraitChanges.profileId, "user_1"))
).toHaveLength(1);

await db().delete(profiles).where(eq(profiles.profileId, "user_1"));

expect(
await db()
.select()
.from(profileTraitChanges)
.where(eq(profileTraitChanges.profileId, "user_1"))
).toHaveLength(0);
});
});
Loading
Loading