| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { convexTest } from "convex-test"; |
| import { describe, expect, test } from "vitest"; |
| import schema from "../schema"; |
| import { api, internal } from "../_generated/api"; |
|
|
| const modules = import.meta.glob("../**/*.ts"); |
|
|
| const USER = { subject: "user-tests-tickers", tokenIdentifier: "clerk|user-tests-tickers" }; |
| const VARIANT = "full"; |
|
|
| async function seedProEntitlement(t: ReturnType<typeof convexTest>) { |
| await t.run(async (ctx) => { |
| await ctx.db.insert("entitlements", { |
| userId: USER.subject, |
| planKey: "pro_monthly", |
| features: { |
| tier: 1, |
| maxDashboards: 10, |
| apiAccess: true, |
| apiRateLimit: 1000, |
| prioritySupport: true, |
| exportFormats: ["json", "csv"], |
| }, |
| validUntil: Date.now() + 30 * 24 * 60 * 60 * 1000, |
| updatedAt: Date.now(), |
| }); |
| }); |
| } |
|
|
| async function readRow(t: ReturnType<typeof convexTest>) { |
| return await t.run(async (ctx) => { |
| return await ctx.db |
| .query("alertRules") |
| .withIndex("by_user_variant", (q) => q.eq("userId", USER.subject).eq("variant", VARIANT)) |
| .unique(); |
| }); |
| } |
|
|
| describe("alertRules.tickers — persistence + normalization", () => { |
| test("plain symbol list is stored as-is", async () => { |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t); |
| const asUser = t.withIdentity(USER); |
| await asUser.mutation(api.alertRules.setAlertRules, { |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: ["watchlist_story_alert"], |
| sensitivity: "critical", |
| channels: [], |
| tickers: ["AAPL", "MSFT"], |
| }); |
| const row = await readRow(t); |
| expect(row?.tickers).toEqual(["AAPL", "MSFT"]); |
| }); |
|
|
| test("normalizes case + whitespace + dedupe (first-seen order)", async () => { |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t); |
| const asUser = t.withIdentity(USER); |
| await asUser.mutation(api.alertRules.setAlertRules, { |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: "critical", |
| channels: [], |
| tickers: ["aapl", "MSFT", "AAPL", " nvda "], |
| }); |
| const row = await readRow(t); |
| expect(row?.tickers).toEqual(["AAPL", "MSFT", "NVDA"]); |
| }); |
|
|
| test("drops malformed shapes, keeps every extractor-emittable symbol shape", async () => { |
| |
| |
| |
| |
| |
| |
| |
| |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t); |
| const asUser = t.withIdentity(USER); |
| await asUser.mutation(api.alertRules.setAlertRules, { |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: "critical", |
| channels: [], |
| tickers: [ |
| "^GSPC", "GC=F", "$AAPL", "", "WAYTOOLONGSYMBOL", "two words", |
| "brk-b", "m&m.ns", "reliance.ns", "BHARTIARTL.NS", "infy.ns", "V", |
| ], |
| }); |
| const row = await readRow(t); |
| expect(row?.tickers).toEqual([ |
| "BRK-B", "M&M.NS", "RELIANCE.NS", "BHARTIARTL.NS", "INFY.NS", "V", |
| ]); |
| }); |
|
|
| test("caps at 50 entries — throws TICKERS_LIMIT_EXCEEDED on 51", async () => { |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t); |
| const asUser = t.withIdentity(USER); |
| const symbols: string[] = []; |
| const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; |
| outer: for (let i = 0; i < letters.length; i++) { |
| for (let j = 0; j < letters.length; j++) { |
| symbols.push(`${letters[i]}${letters[j]}X`); |
| if (symbols.length >= 51) break outer; |
| } |
| } |
| await expect( |
| asUser.mutation(api.alertRules.setAlertRules, { |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: "critical", |
| channels: [], |
| tickers: symbols, |
| }), |
| ).rejects.toThrow(/TICKERS_LIMIT_EXCEEDED|capped at 50/); |
| }); |
|
|
| test("backward compat: insert without tickers field → field absent on row", async () => { |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t); |
| const asUser = t.withIdentity(USER); |
| await asUser.mutation(api.alertRules.setAlertRules, { |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: "critical", |
| channels: [], |
| }); |
| const row = await readRow(t); |
| expect(row).not.toBeNull(); |
| expect(row?.tickers).toBeUndefined(); |
| }); |
|
|
| test("preserve-on-omit: existing tickers:['AAPL'] + caller omits → row still has ['AAPL']", async () => { |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t); |
| const asUser = t.withIdentity(USER); |
| await asUser.mutation(api.alertRules.setAlertRules, { |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: ["watchlist_story_alert"], |
| sensitivity: "critical", |
| channels: [], |
| tickers: ["AAPL"], |
| }); |
| |
| await asUser.mutation(api.alertRules.setAlertRules, { |
| variant: VARIANT, |
| enabled: false, |
| eventTypes: ["watchlist_story_alert"], |
| sensitivity: "critical", |
| channels: [], |
| }); |
| const row = await readRow(t); |
| expect(row?.tickers).toEqual(["AAPL"]); |
| expect(row?.enabled).toBe(false); |
| }); |
|
|
| test("explicit reset: existing tickers:['AAPL'] + caller passes [] → row stores []", async () => { |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t); |
| const asUser = t.withIdentity(USER); |
| await asUser.mutation(api.alertRules.setAlertRules, { |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: "critical", |
| channels: [], |
| tickers: ["AAPL"], |
| }); |
| await asUser.mutation(api.alertRules.setAlertRules, { |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: "critical", |
| channels: [], |
| tickers: [], |
| }); |
| const row = await readRow(t); |
| expect(row?.tickers).toEqual([]); |
| }); |
|
|
| test("setAlertRulesForUser (internal, /notification-channels set-alert-rules path) forwards + normalizes tickers", async () => { |
| const t = convexTest(schema, modules); |
| await t.mutation(internal.alertRules.setAlertRulesForUser, { |
| userId: USER.subject, |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: ["watchlist_story_alert"], |
| sensitivity: "critical", |
| channels: [], |
| tickers: ["nvda", " AAPL", "NVDA"], |
| }); |
| const row = await readRow(t); |
| expect(row?.tickers).toEqual(["NVDA", "AAPL"]); |
| }); |
|
|
| test("setNotificationConfigForUser also forwards + normalizes tickers", async () => { |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t); |
| await t.mutation(internal.alertRules.setNotificationConfigForUser, { |
| userId: USER.subject, |
| variant: VARIANT, |
| enabled: true, |
| eventTypes: ["watchlist_story_alert"], |
| sensitivity: "critical", |
| channels: [], |
| tickers: ["tsla", " amd", "TSLA"], |
| }); |
| const row = await readRow(t); |
| expect(row?.tickers).toEqual(["TSLA", "AMD"]); |
| }); |
| }); |
|
|