diff --git a/.gitignore b/.gitignore index ca06ea8e..281d6382 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ coverage/ # Other CLAUDE.md CLAUDE_NOTES.md + +# CodeGraph (local code-intelligence index, machine-specific) +.codegraph/ diff --git a/src/cyd-api-client.ts b/src/cyd-api-client.ts index 402402bf..3a07ec31 100644 --- a/src/cyd-api-client.ts +++ b/src/cyd-api-client.ts @@ -1,3 +1,6 @@ +// Desktop-scoped API client for cyd. +// Keep this file limited to methods and models used by desktop code. + // API error response export type APIErrorResponse = { error: boolean; @@ -23,17 +26,6 @@ export type RegisterDeviceAPIResponse = { device_token: string; }; -// API models for GET /device (an array of these) -export type GetDevicesAPIResponse = { - uuid: string; - description: string; - last_accessed_at: Date; -}; - -export type GetDevicesAPIResponseArray = { - devices: GetDevicesAPIResponse[]; -}; - // API models for POST /token export type TokenAPIRequest = { email: string; @@ -77,58 +69,23 @@ export type PostFacebookProgressAPIRequest = { total_wall_posts_hidden: number; }; +export type BillingPeriod = "annual" | "monthly"; +export type CurrentBillingPeriod = BillingPeriod | "none"; + // API models for GET /user/premium export type UserPremiumAPIResponse = { - premium_price_cents: number; + premium_price_annual_cents: number; + premium_price_monthly_cents: number; premium_business_price_cents: number; premium_access: boolean; has_individual_subscription: boolean; subscription_cancel_at_period_end: boolean; - subscription_current_period_end: string; + subscription_current_period_end: string | null; has_business_subscription: boolean; business_organizations: string[]; -}; - -// API models for POST /user/invoices (an array of these) -export type GetUserInvoicesAPIResponse = { - created_at: string; - hosted_invoice_url: string; - status: string; - total: number; -}; - -// API models for POST /user/premium -export type PostUserPremiumAPIRequest = { - promotion_code: string; -}; - -export type PostUserPremiumAPIResponse = { - redirect_url: string; -}; - -// API models for POST /user/coupon-code -export type PostUserCouponCodeAPIRequest = { - promotion_code: string; -}; - -export type PostUserCouponCodeAPIResponse = { - valid: boolean; - currency?: string; - duration?: string; - duration_in_months?: number; - amount_off?: number; - percent_off?: number; - name?: string; -}; - -// API models for GET /user/stats -export type UserStatsAPIResponse = { - total_tweets_archived: number; - total_messages_indexed: number; - total_tweets_deleted: number; - total_retweets_deleted: number; - total_likes_deleted: number; - total_conversations_deleted: number; + current_billing_period: CurrentBillingPeriod; + partner: boolean; + stored_credit_cents: number; }; // API models for POST /automation-error-report @@ -159,33 +116,27 @@ export default class CydAPIClient { private userEmail: string | null = null; private deviceToken: string | null = null; private apiToken: string | null = null; - private deviceUUID: string | null = null; constructor() { this.apiURL = null; this.userEmail = null; this.deviceToken = null; this.apiToken = null; - this.deviceUUID = null; } initialize(APIURL: string): void { this.apiURL = APIURL; } - setUserEmail(userEmail: string) { + setUserEmail(userEmail: string): void { this.userEmail = userEmail; } - async setDeviceToken(deviceToken: string) { + async setDeviceToken(deviceToken: string): Promise { this.deviceToken = deviceToken; await this.getNewAPIToken(); } - getDeviceUUID(): string | null { - return this.deviceUUID; - } - returnError(message: string, status?: number): APIErrorResponse { const apiErrorResponse: APIErrorResponse = { error: true, @@ -232,7 +183,7 @@ export default class CydAPIClient { return; } - // Try to get a new token, and then try one more time + // Try to get a new token, and then try one more time. console.log( "Failed to authenticate with the server. Trying to get a new API token.", ); @@ -356,9 +307,6 @@ export default class CydAPIClient { const data: TokenAPIResponse = await response.json(); this.apiToken = data.api_token; - - // Set the device UUID - this.deviceUUID = data.device_uuid; return data; } catch { return this.returnError( @@ -395,29 +343,6 @@ export default class CydAPIClient { } } - async getDevices(): Promise { - console.log("GET /device"); - if (!(await this.validateAPIToken())) { - return this.returnError("Failed to get a new API token."); - } - try { - const response = await this.fetchAuthenticated( - "GET", - `${this.apiURL}/device`, - null, - ); - if (response.status != 200) { - return this.returnError("Failed to get devices.", response.status); - } - const data: GetDevicesAPIResponse[] = await response.json(); - return { devices: data }; - } catch { - return this.returnError( - "Failed to get devices. Maybe the server is down?", - ); - } - } - async ping(): Promise { console.log("GET /ping"); if (!(await this.validateAPIToken())) { @@ -444,27 +369,18 @@ export default class CydAPIClient { ): Promise { console.log("POST /x-progress", request); - if (authenticated) { - if (!(await this.validateAPIToken())) { - return this.returnError("Failed to get a new API token."); - } + if (authenticated && !(await this.validateAPIToken())) { + return this.returnError("Failed to get a new API token."); } try { - let response; - if (authenticated) { - response = await this.fetchAuthenticated( - "POST", - `${this.apiURL}/x-progress`, - request, - ); - } else { - response = await this.fetch( - "POST", - `${this.apiURL}/x-progress`, - request, - ); - } + const response = authenticated + ? await this.fetchAuthenticated( + "POST", + `${this.apiURL}/x-progress`, + request, + ) + : await this.fetch("POST", `${this.apiURL}/x-progress`, request); if (response.status != 200) { return this.returnError( @@ -500,27 +416,18 @@ export default class CydAPIClient { ): Promise { console.log("POST /facebook-progress", request); - if (authenticated) { - if (!(await this.validateAPIToken())) { - return this.returnError("Failed to get a new API token."); - } + if (authenticated && !(await this.validateAPIToken())) { + return this.returnError("Failed to get a new API token."); } try { - let response; - if (authenticated) { - response = await this.fetchAuthenticated( - "POST", - `${this.apiURL}/facebook-progress`, - request, - ); - } else { - response = await this.fetch( - "POST", - `${this.apiURL}/facebook-progress`, - request, - ); - } + const response = authenticated + ? await this.fetchAuthenticated( + "POST", + `${this.apiURL}/facebook-progress`, + request, + ) + : await this.fetch("POST", `${this.apiURL}/facebook-progress`, request); if (response.status != 200) { return this.returnError( @@ -578,164 +485,6 @@ export default class CydAPIClient { } } - async getUserInvoices(): Promise< - GetUserInvoicesAPIResponse[] | APIErrorResponse - > { - console.log("GET /user/invoices"); - if (!(await this.validateAPIToken())) { - return this.returnError("Failed to get a new API token."); - } - try { - const response = await this.fetchAuthenticated( - "GET", - `${this.apiURL}/user/invoices`, - null, - ); - if (response.status != 200) { - return this.returnError("Failed to get invoices.", response.status); - } - const data: GetUserInvoicesAPIResponse[] = await response.json(); - return data; - } catch { - return this.returnError( - "Failed to get invoices. Maybe the server is down?", - ); - } - } - - async postUserPremium( - promotion_code: string, - ): Promise { - console.log(`POST /user/premium ${promotion_code}`); - if (!(await this.validateAPIToken())) { - return this.returnError("Failed to get a new API token."); - } - try { - const response = await this.fetchAuthenticated( - "POST", - `${this.apiURL}/user/premium`, - { - promotion_code: promotion_code, - }, - ); - if (response.status != 200) { - return this.returnError( - "Failed to upgrade user to premium.", - response.status, - ); - } - const data: PostUserPremiumAPIResponse = await response.json(); - return data; - } catch { - return this.returnError( - "Failed to upgrade user to premium. Maybe the server is down?", - ); - } - } - - async putUserPremium(action: string): Promise { - console.log("PUT /user/premium"); - if (!(await this.validateAPIToken())) { - return this.returnError("Failed to get a new API token."); - } - try { - const response = await this.fetchAuthenticated( - "PUT", - `${this.apiURL}/user/premium`, - { action: action }, - ); - if (response.status != 200) { - return this.returnError( - "Failed to update subscription.", - response.status, - ); - } - return true; - } catch { - return this.returnError( - "Failed to update subscription. Maybe the server is down?", - ); - } - } - - async postUserCouponCode( - promotion_code: string, - ): Promise { - console.log("POST /user/coupon-code"); - if (!(await this.validateAPIToken())) { - return this.returnError("Failed to get a new API token."); - } - try { - const response = await this.fetchAuthenticated( - "POST", - `${this.apiURL}/user/coupon-code`, - { - promotion_code: promotion_code, - }, - ); - if (response.status != 200) { - return this.returnError( - "Failed to apply coupon code.", - response.status, - ); - } - const data: PostUserCouponCodeAPIResponse = await response.json(); - return data; - } catch { - return this.returnError( - "Failed to apply coupon code. Maybe the server is down?", - ); - } - } - - async deleteUserPremium(): Promise { - console.log("DELETE /user/premium"); - if (!(await this.validateAPIToken())) { - return this.returnError("Failed to get a new API token."); - } - try { - const response = await this.fetchAuthenticated( - "DELETE", - `${this.apiURL}/user/premium`, - null, - ); - if (response.status != 200) { - return this.returnError( - "Failed to cancel subscription.", - response.status, - ); - } - return true; - } catch { - return this.returnError( - "Failed to cancel subscription. Maybe the server is down?", - ); - } - } - - async getUserStats(): Promise { - console.log("GET /user/stats"); - if (!(await this.validateAPIToken())) { - return this.returnError("Failed to get a new API token."); - } - try { - const response = await this.fetchAuthenticated( - "GET", - `${this.apiURL}/user/stats`, - null, - ); - if (response.status != 200) { - return this.returnError("Failed to get user stats.", response.status); - } - const data: UserStatsAPIResponse = await response.json(); - return data; - } catch { - return this.returnError( - "Failed to get user stats. Maybe the server is down?", - ); - } - } - // Submit automation error report async postAutomationErrorReport( @@ -744,27 +493,23 @@ export default class CydAPIClient { ): Promise { console.log("POST /automation-error-report", request, authenticated); - if (authenticated) { - if (!(await this.validateAPIToken())) { - return this.returnError("Failed to get a new API token."); - } + if (authenticated && !(await this.validateAPIToken())) { + return this.returnError("Failed to get a new API token."); } try { - let response; - if (authenticated) { - response = await this.fetchAuthenticated( - "POST", - `${this.apiURL}/automation-error-report`, - request, - ); - } else { - response = await this.fetch( - "POST", - `${this.apiURL}/automation-error-report`, - request, - ); - } + const response = authenticated + ? await this.fetchAuthenticated( + "POST", + `${this.apiURL}/automation-error-report`, + request, + ) + : await this.fetch( + "POST", + `${this.apiURL}/automation-error-report`, + request, + ); + if (response.status != 200) { return this.returnError( "Failed to post automation error report with the server.", diff --git a/src/database.test.ts b/src/database.test.ts index 13482ce7..7d834772 100644 --- a/src/database.test.ts +++ b/src/database.test.ts @@ -7,12 +7,13 @@ import { beforeEach, afterEach, test, expect, vi } from "vitest"; vi.mock("./util", () => ({ ...vi.importActual("./util"), // Import and spread the actual implementations getSettingsPath: vi.fn(() => { - const settingsPath = path.join( - __dirname, - "..", - "testdata", - "settingsPath-database", - ); + // Use the per-worker isolated path set up in test-setup.ts. Falling back + // to the shared parent directory here caused parallel workers to delete + // each other's databases during afterEach cleanup. + const settingsPath = + process.env.TEST_MODE === "1" && process.env.TEST_SETTINGS_PATH + ? process.env.TEST_SETTINGS_PATH + : path.join(__dirname, "..", "testdata", "settingsPath-database"); if (!fs.existsSync(settingsPath)) { fs.mkdirSync(settingsPath, { recursive: true }); } diff --git a/src/renderer/src/composables/usePlatformView.test.ts b/src/renderer/src/composables/usePlatformView.test.ts index 03bdb16f..a115dcb0 100644 --- a/src/renderer/src/composables/usePlatformView.test.ts +++ b/src/renderer/src/composables/usePlatformView.test.ts @@ -14,6 +14,22 @@ import { import type { BasePlatformViewModel } from "../types/PlatformView"; import type { PlatformConfig } from "../types/PlatformConfig"; +const createUserPremiumResponse = (overrides = {}) => ({ + premium_price_annual_cents: 3600, + premium_price_monthly_cents: 400, + premium_business_price_cents: 1337, + premium_access: true, + has_individual_subscription: false, + subscription_cancel_at_period_end: false, + subscription_current_period_end: null, + has_business_subscription: false, + business_organizations: [], + current_billing_period: "none", + partner: false, + stored_credit_cents: 0, + ...overrides, +}); + // Create a mock view model const createMockViewModel = (): BasePlatformViewModel & { run: () => Promise; @@ -71,7 +87,7 @@ describe("usePlatformView", () => { apiClient.ping = vi.fn().mockResolvedValue(true); apiClient.getUserPremium = vi .fn() - .mockResolvedValue({ premium_access: true }); + .mockResolvedValue(createUserPremiumResponse()); }); afterEach(() => { diff --git a/src/renderer/src/view_models/XViewModel/auth.test.ts b/src/renderer/src/view_models/XViewModel/auth.test.ts index 08d0a9f9..1e86d8ca 100644 --- a/src/renderer/src/view_models/XViewModel/auth.test.ts +++ b/src/renderer/src/view_models/XViewModel/auth.test.ts @@ -106,7 +106,7 @@ describe("auth.ts", () => { expect(mockVM.loadURLWithRateLimit).toHaveBeenCalledWith( "https://x.com/login", - ["https://x.com/home", "https://x.com/i/flow/login"], + ["https://x.com/home", "https://x.com/i/"], ); expect(mockVM.waitForURL).toHaveBeenCalledWith("https://x.com/home"); }); diff --git a/src/renderer/src/views/AccountView.test.ts b/src/renderer/src/views/AccountView.test.ts index f21477d7..81c1b823 100644 --- a/src/renderer/src/views/AccountView.test.ts +++ b/src/renderer/src/views/AccountView.test.ts @@ -180,6 +180,13 @@ describe("AccountView", () => { }); it("should show Facebook option", async () => { + window.electron.isFeatureEnabled = vi + .fn() + .mockImplementation((feature: string) => { + if (feature === "bluesky") return Promise.resolve(true); + return Promise.resolve(false); + }); + const unknownAccount = createMockAccount({ type: "unknown" }); wrapper = mount(AccountView, { @@ -199,6 +206,13 @@ describe("AccountView", () => { }); it("should emit accountSelected when Facebook card clicked", async () => { + window.electron.isFeatureEnabled = vi + .fn() + .mockImplementation((feature: string) => { + if (feature === "bluesky") return Promise.resolve(true); + return Promise.resolve(false); + }); + const unknownAccount = createMockAccount({ type: "unknown" }); wrapper = mount(AccountView, { diff --git a/src/renderer/src/views/shared_components/UpsellComponent.test.ts b/src/renderer/src/views/shared_components/UpsellComponent.test.ts index 085b5a11..49693f3a 100644 --- a/src/renderer/src/views/shared_components/UpsellComponent.test.ts +++ b/src/renderer/src/views/shared_components/UpsellComponent.test.ts @@ -12,6 +12,23 @@ vi.mock("../../util", () => ({ // Mock CydAPIClient const mockPing = vi.fn(); const mockGetUserPremium = vi.fn(); + +const createUserPremiumResponse = (overrides = {}) => ({ + premium_price_annual_cents: 3600, + premium_price_monthly_cents: 400, + premium_business_price_cents: 1337, + premium_access: false, + has_individual_subscription: false, + subscription_cancel_at_period_end: false, + subscription_current_period_end: null, + has_business_subscription: false, + business_organizations: [], + current_billing_period: "none", + partner: false, + stored_credit_cents: 0, + ...overrides, +}); + vi.mock("../../../../cyd-api-client", () => { return { default: class CydAPIClient { @@ -47,10 +64,7 @@ describe("UpsellComponent", () => { // Default mock responses mockPing.mockResolvedValue(true); - mockGetUserPremium.mockResolvedValue({ - premium_access: false, - has_business_subscription: false, - }); + mockGetUserPremium.mockResolvedValue(createUserPremiumResponse()); }); afterEach(() => { @@ -88,10 +102,9 @@ describe("UpsellComponent", () => { }); it("should show premium thank you message when user has premium", async () => { - mockGetUserPremium.mockResolvedValue({ - premium_access: true, - has_business_subscription: false, - }); + mockGetUserPremium.mockResolvedValue( + createUserPremiumResponse({ premium_access: true }), + ); const wrapper = mount(UpsellComponent, { global: { @@ -122,10 +135,12 @@ describe("UpsellComponent", () => { }); it("should show business subscription message when user has business subscription", async () => { - mockGetUserPremium.mockResolvedValue({ - premium_access: true, - has_business_subscription: true, - }); + mockGetUserPremium.mockResolvedValue( + createUserPremiumResponse({ + premium_access: true, + has_business_subscription: true, + }), + ); const wrapper = mount(UpsellComponent, { global: { @@ -320,9 +335,7 @@ describe("UpsellComponent", () => { donateButton.element.dispatchEvent(clickEvent); await wrapper.vm.$nextTick(); - expect(openURL).toHaveBeenCalledWith( - "https://opencollective.com/lockdown-systems", - ); + expect(openURL).toHaveBeenCalledWith("https://lockdown.systems/donate/"); }); it("should register emitter listeners on mount", async () => {