mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 12:12:25 +09:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
698469fa96 | ||
|
|
591e9fe2ba | ||
|
|
d148b4e5b7 | ||
|
|
324f333bb5 |
@@ -434,41 +434,6 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_instance_config_hides_self_host_setup_controls() {
|
||||
let app = setup().await;
|
||||
let body = get(&app, "/instance-config", &[]).await;
|
||||
|
||||
assert_full_layout(&body);
|
||||
assert!(body.contains("Registration Controls"), "{body}");
|
||||
assert!(body.contains("Runtime Integrations"), "{body}");
|
||||
assert!(body.contains("Gateway Rollout Configuration"), "{body}");
|
||||
assert!(!body.contains("Public App Identity"), "{body}");
|
||||
assert!(!body.contains("Setup complete"), "{body}");
|
||||
assert!(!body.contains("Community & Policy"), "{body}");
|
||||
assert!(!body.contains("Single community"), "{body}");
|
||||
assert!(!body.contains("Direct messages & friends"), "{body}");
|
||||
assert!(!body.contains("Premium model"), "{body}");
|
||||
assert!(!body.contains("Optional services"), "{body}");
|
||||
assert!(!body.contains("Registration Fields"), "{body}");
|
||||
assert!(
|
||||
!body.contains("Collect date of birth during registration"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("/instance-config?action=update_app_public"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("/instance-config?action=update_app_registration"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("/instance-config?action=update_policy"),
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn instance_config_registration_tables_show_copyable_urls_and_compact_pending_actions() {
|
||||
let app = setup().await;
|
||||
|
||||
@@ -27402,9 +27402,9 @@
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 25,
|
||||
"maximum": 50,
|
||||
"format": "int32",
|
||||
"description": "Number of messages to return for this channel (1-25)"
|
||||
"description": "Number of messages to return for this channel (1-50)"
|
||||
},
|
||||
"before": {"$ref": "#/components/schemas/SnowflakeType"},
|
||||
"after": {"$ref": "#/components/schemas/SnowflakeType"},
|
||||
@@ -33253,12 +33253,20 @@
|
||||
"payment_intent_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"charge_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"refund_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"refunded_amount_cents": {"type": "integer", "format": "int53"},
|
||||
"refunded_amount_cents": {
|
||||
"type": "integer",
|
||||
"format": "int53",
|
||||
"description": "Amount actually refunded so far, in the currency minor unit; 0 until the provider confirms success"
|
||||
},
|
||||
"invoice_amount_paid_cents": {"type": "integer", "format": "int53"},
|
||||
"currency": {"type": "string"},
|
||||
"subscription_id": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Subscription that was cancelled along with the refund, when applicable"
|
||||
},
|
||||
"status": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Provider status of the refund (e.g. pending, succeeded, failed); money only moved once succeeded"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -33269,7 +33277,8 @@
|
||||
"refunded_amount_cents",
|
||||
"invoice_amount_paid_cents",
|
||||
"currency",
|
||||
"subscription_id"
|
||||
"subscription_id",
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"ReadStateAckResponse": {
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
SelfServeRefundResponse,
|
||||
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import type Stripe from 'stripe';
|
||||
import type {UserID} from '../../BrandedTypes';
|
||||
import {createUserID, type UserID} from '../../BrandedTypes';
|
||||
import {Config} from '../../Config';
|
||||
import {Logger} from '../../Logger';
|
||||
import {getBillingRepository} from '../../middleware/ServiceRegistry';
|
||||
@@ -206,6 +206,51 @@ export class StripeRefundService {
|
||||
};
|
||||
}
|
||||
|
||||
private async countPriorTerminalFailures(invoiceId: string): Promise<number> {
|
||||
const priorRefunds = await getBillingRepository().refunds.listByInvoice(invoiceId);
|
||||
return priorRefunds.filter((r) => r.status === 'failed' || r.status === 'canceled').length;
|
||||
}
|
||||
|
||||
private async finalizeIfSucceeded(refund: Stripe.Refund): Promise<void> {
|
||||
if (refund.status !== 'succeeded' || refund.metadata?.refund_kind !== 'self_serve') {
|
||||
return;
|
||||
}
|
||||
const userIdRaw = refund.metadata.user_id;
|
||||
if (!userIdRaw) {
|
||||
return;
|
||||
}
|
||||
let userId: UserID;
|
||||
try {
|
||||
userId = createUserID(BigInt(userIdRaw));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user || user.firstRefundAt) {
|
||||
return;
|
||||
}
|
||||
const subscriptionId = refund.metadata.subscription_id;
|
||||
if (subscriptionId) {
|
||||
try {
|
||||
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{error, userId: user.id.toString(), subscriptionId},
|
||||
'Self-serve refund confirmed but subscription cancellation failed; will reconcile via webhook',
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.userRepository.patchUpsert(user.id, {first_refund_at: new Date()}, user.toRow());
|
||||
Logger.info(
|
||||
{userId: user.id.toString(), refundId: refund.id, subscriptionId: subscriptionId || null},
|
||||
'Self-serve refund confirmed succeeded; cooldown and cancellation finalized',
|
||||
);
|
||||
}
|
||||
|
||||
async handleRefundWebhookEvent(refund: Stripe.Refund): Promise<void> {
|
||||
await this.finalizeIfSucceeded(refund);
|
||||
}
|
||||
|
||||
async refundLatestPurchase(userId: UserID): Promise<SelfServeRefundResponse> {
|
||||
const stripe = this.ensureStripe();
|
||||
const user = await this.getRequiredUser(userId);
|
||||
@@ -220,6 +265,14 @@ export class StripeRefundService {
|
||||
if (this.cooldownExpiresAt(user)) {
|
||||
throw new StripeRefundCooldownActiveError();
|
||||
}
|
||||
const priorFailures = await this.countPriorTerminalFailures(target.invoiceId);
|
||||
const idempotencyKey = [
|
||||
'self-serve-refund',
|
||||
user.id.toString(),
|
||||
target.invoiceId,
|
||||
target.paymentIntentId ?? target.chargeId,
|
||||
...(priorFailures > 0 ? [`retry-${priorFailures}`] : []),
|
||||
].join(':');
|
||||
let refund: Stripe.Response<Stripe.Refund>;
|
||||
try {
|
||||
refund = await stripe.refunds.create(
|
||||
@@ -232,11 +285,10 @@ export class StripeRefundService {
|
||||
invoice_id: target.invoiceId,
|
||||
refund_kind: 'self_serve',
|
||||
refund_window_days: String(SELF_SERVE_REFUND_WINDOW_DAYS),
|
||||
...(target.subscriptionId ? {subscription_id: target.subscriptionId} : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
idempotencyKey: `self-serve-refund:${user.id}:${target.invoiceId}:${target.paymentIntentId ?? target.chargeId}`,
|
||||
},
|
||||
{idempotencyKey},
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
@@ -254,36 +306,29 @@ export class StripeRefundService {
|
||||
} catch (mirrorErr) {
|
||||
Logger.error({mirrorErr, refundId: refund.id}, 'Mirror upsert failed after Stripe write; reconciler will heal');
|
||||
}
|
||||
if (target.subscriptionId) {
|
||||
try {
|
||||
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{error, userId: user.id.toString(), subscriptionId: target.subscriptionId},
|
||||
'Self-serve refund issued but subscription cancellation failed; will reconcile via webhook',
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.userRepository.patchUpsert(user.id, {first_refund_at: new Date()}, user.toRow());
|
||||
await this.finalizeIfSucceeded(refund);
|
||||
const succeeded = refund.status === 'succeeded';
|
||||
Logger.info(
|
||||
{
|
||||
userId: user.id.toString(),
|
||||
invoiceId: target.invoiceId,
|
||||
refundId: refund.id,
|
||||
status: refund.status,
|
||||
amountCents: refund.amount,
|
||||
subscriptionId: target.subscriptionId,
|
||||
},
|
||||
'Self-serve refund issued',
|
||||
succeeded ? 'Self-serve refund issued' : 'Self-serve refund created; awaiting confirmation from provider',
|
||||
);
|
||||
return {
|
||||
invoice_id: target.invoiceId,
|
||||
payment_intent_id: target.paymentIntentId,
|
||||
charge_id: target.chargeId,
|
||||
refund_id: refund.id,
|
||||
refunded_amount_cents: refund.amount,
|
||||
refunded_amount_cents: succeeded ? refund.amount : 0,
|
||||
invoice_amount_paid_cents: target.amountPaidCents,
|
||||
currency: target.currency,
|
||||
subscription_id: target.subscriptionId,
|
||||
subscription_id: succeeded ? target.subscriptionId : null,
|
||||
status: refund.status ?? 'pending',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {StripeGiftReversalHandler} from './StripeGiftReversalHandler';
|
||||
import type {StripeGiftService} from './StripeGiftService';
|
||||
import {StripePaymentFraudService} from './StripePaymentFraudService';
|
||||
import type {StripePremiumService} from './StripePremiumService';
|
||||
import type {StripeRefundService} from './StripeRefundService';
|
||||
import {StripeSubscriptionReconciler} from './StripeSubscriptionReconciler';
|
||||
import {StripeSubscriptionWebhookHandler} from './StripeSubscriptionWebhookHandler';
|
||||
|
||||
@@ -61,6 +62,7 @@ export class StripeWebhookService {
|
||||
adminRepository: AdminRepository,
|
||||
snowflakeService: ISnowflakeService,
|
||||
private billingRepository: BillingRepository,
|
||||
private refundService: StripeRefundService,
|
||||
) {
|
||||
this.checkoutHandler = new StripeCheckoutWebhookHandler(
|
||||
stripe,
|
||||
@@ -336,6 +338,7 @@ export class StripeWebhookService {
|
||||
livemode: event.livemode,
|
||||
}),
|
||||
);
|
||||
await this.refundService.handleRefundWebhookEvent(r);
|
||||
break;
|
||||
}
|
||||
case 'invoice.created':
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import {HttpResponse, http} from 'msw';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount, type TestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createStripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
|
||||
import {server} from '../../test/msw/server';
|
||||
@@ -105,18 +106,30 @@ function invoiceListHandler(invoices: ReadonlyArray<MockStripeInvoice>) {
|
||||
});
|
||||
}
|
||||
|
||||
function refundCreateHandler() {
|
||||
function refundCreateHandler(opts?: {
|
||||
status?: 'succeeded' | 'pending' | 'failed';
|
||||
failureReason?: string;
|
||||
onRequest?: (idempotencyKey: string | null) => void;
|
||||
}) {
|
||||
return http.post(`${STRIPE_API_BASE}/v1/refunds`, async ({request}) => {
|
||||
opts?.onRequest?.(request.headers.get('idempotency-key'));
|
||||
const formData = await request.formData();
|
||||
const params = Object.fromEntries(formData.entries());
|
||||
const metadata: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
const match = key.match(/^metadata\[(.+)\]$/);
|
||||
if (match) metadata[match[1]] = value as string;
|
||||
}
|
||||
return HttpResponse.json({
|
||||
id: 're_test_self_serve',
|
||||
object: 'refund',
|
||||
amount: Number.parseInt((params.amount as string) ?? '0', 10),
|
||||
currency: 'usd',
|
||||
status: 'succeeded',
|
||||
status: opts?.status ?? 'succeeded',
|
||||
failure_reason: opts?.failureReason ?? null,
|
||||
payment_intent: params.payment_intent ?? null,
|
||||
charge: params.charge ?? null,
|
||||
metadata,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -274,5 +287,62 @@ describe('StripeRefundService self-serve refund', () => {
|
||||
.expect(400, APIErrorCodes.STRIPE_NO_PURCHASE_HISTORY)
|
||||
.execute();
|
||||
});
|
||||
test('does not finalize cooldown or cancel the subscription while the refund is still pending at the provider', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
server.use(
|
||||
invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]),
|
||||
refundCreateHandler({status: 'pending'}),
|
||||
);
|
||||
const account = await createTestAccount(harness);
|
||||
await setStripeIds(harness, account, {
|
||||
stripe_customer_id: MOCK_CUSTOMER_ID,
|
||||
stripe_subscription_id: MOCK_SUBSCRIPTION_ID,
|
||||
});
|
||||
const response = await createBuilder<SelfServeRefundResponse>(harness, account.token)
|
||||
.post('/premium/refund-latest')
|
||||
.execute();
|
||||
expect(response.status).toBe('pending');
|
||||
expect(response.refunded_amount_cents).toBe(0);
|
||||
expect(response.subscription_id).toBeNull();
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const updatedUser = await new UserRepository().findUnique(createUserID(BigInt(account.userId)));
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
});
|
||||
test('does not finalize cooldown or cancel the subscription when the refund fails at the provider', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
server.use(
|
||||
invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]),
|
||||
refundCreateHandler({status: 'failed', failureReason: 'unknown'}),
|
||||
);
|
||||
const account = await createTestAccount(harness);
|
||||
await setStripeIds(harness, account, {
|
||||
stripe_customer_id: MOCK_CUSTOMER_ID,
|
||||
stripe_subscription_id: MOCK_SUBSCRIPTION_ID,
|
||||
});
|
||||
const response = await createBuilder<SelfServeRefundResponse>(harness, account.token)
|
||||
.post('/premium/refund-latest')
|
||||
.execute();
|
||||
expect(response.status).toBe('failed');
|
||||
expect(response.refunded_amount_cents).toBe(0);
|
||||
expect(response.subscription_id).toBeNull();
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const updatedUser = await new UserRepository().findUnique(createUserID(BigInt(account.userId)));
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
});
|
||||
test('retries with a fresh idempotency key once a prior attempt has failed at the provider', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
server.use(invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]));
|
||||
const account = await createTestAccount(harness);
|
||||
await setStripeIds(harness, account, {stripe_customer_id: MOCK_CUSTOMER_ID});
|
||||
const idempotencyKeys: Array<string | null> = [];
|
||||
server.use(refundCreateHandler({status: 'failed', onRequest: (key) => idempotencyKeys.push(key)}));
|
||||
await createBuilder<SelfServeRefundResponse>(harness, account.token).post('/premium/refund-latest').execute();
|
||||
await createBuilder<SelfServeRefundResponse>(harness, account.token).post('/premium/refund-latest').execute();
|
||||
expect(idempotencyKeys).toHaveLength(2);
|
||||
expect(idempotencyKeys[0]).not.toBeNull();
|
||||
expect(idempotencyKeys[1]).not.toBeNull();
|
||||
expect(idempotencyKeys[1]).not.toBe(idempotencyKeys[0]);
|
||||
expect(idempotencyKeys[1]).toContain('retry-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {HttpResponse, http} from 'msw';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {Config} from '../../Config';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createMockWebhookPayload, type StripeWebhookEventData} from '../../test/msw/handlers/StripeApiHandlers';
|
||||
import {server} from '../../test/msw/server';
|
||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||
import {UserRepository} from '../../user/repositories/UserRepository';
|
||||
import {setupSyncStripeWebhookWorker} from './StripeWebhookTestUtils';
|
||||
@@ -341,4 +343,90 @@ describe('Stripe Webhook Refund', () => {
|
||||
expect(updatedRedeemer.premium_type).toBe(UserPremiumTypes.LIFETIME);
|
||||
});
|
||||
});
|
||||
describe('refund.updated', () => {
|
||||
test('finalizes self-serve cooldown and cancels the subscription once the refund is confirmed succeeded', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const userRepository = new UserRepository();
|
||||
const subscriptionId = 'sub_test_webhook_finalize';
|
||||
await userRepository.patchUpsert(
|
||||
userId,
|
||||
{stripe_subscription_id: subscriptionId},
|
||||
(await userRepository.findUnique(userId))!.toRow(),
|
||||
);
|
||||
server.use(
|
||||
http.delete('https://api.stripe.com/v1/subscriptions/:id', ({params}) =>
|
||||
HttpResponse.json({id: params.id, object: 'subscription', status: 'canceled'}),
|
||||
),
|
||||
);
|
||||
await sendWebhook({
|
||||
type: 'refund.updated',
|
||||
data: {
|
||||
object: {
|
||||
id: 'pyr_test_webhook_finalize',
|
||||
status: 'succeeded',
|
||||
amount: 2499,
|
||||
currency: 'brl',
|
||||
metadata: {
|
||||
refund_kind: 'self_serve',
|
||||
user_id: account.userId.toString(),
|
||||
invoice_id: 'in_test_webhook_finalize',
|
||||
subscription_id: subscriptionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const updatedUser = await userRepository.findUnique(userId);
|
||||
expect(updatedUser!.firstRefundAt).not.toBeNull();
|
||||
});
|
||||
test('does not finalize cooldown while the refund is still pending', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const userRepository = new UserRepository();
|
||||
await sendWebhook({
|
||||
type: 'refund.updated',
|
||||
data: {
|
||||
object: {
|
||||
id: 'pyr_test_webhook_pending',
|
||||
status: 'pending',
|
||||
amount: 2499,
|
||||
currency: 'brl',
|
||||
metadata: {
|
||||
refund_kind: 'self_serve',
|
||||
user_id: account.userId.toString(),
|
||||
invoice_id: 'in_test_webhook_pending',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const updatedUser = await userRepository.findUnique(userId);
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
});
|
||||
});
|
||||
describe('refund.failed', () => {
|
||||
test('does not finalize cooldown when the refund ultimately fails', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const userRepository = new UserRepository();
|
||||
await sendWebhook({
|
||||
type: 'refund.failed',
|
||||
data: {
|
||||
object: {
|
||||
id: 'pyr_test_webhook_failed',
|
||||
status: 'failed',
|
||||
failure_reason: 'unknown',
|
||||
amount: 2499,
|
||||
currency: 'brl',
|
||||
metadata: {
|
||||
refund_kind: 'self_serve',
|
||||
user_id: account.userId.toString(),
|
||||
invoice_id: 'in_test_webhook_failed',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const updatedUser = await userRepository.findUnique(userId);
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {AgeVerificationService} from '../../stripe/services/AgeVerificationServi
|
||||
import {StripeCheckoutService} from '../../stripe/services/StripeCheckoutService';
|
||||
import {StripeGiftService} from '../../stripe/services/StripeGiftService';
|
||||
import {StripePremiumService} from '../../stripe/services/StripePremiumService';
|
||||
import {StripeRefundService} from '../../stripe/services/StripeRefundService';
|
||||
import {StripeSubscriptionService} from '../../stripe/services/StripeSubscriptionService';
|
||||
import {StripeWebhookService} from '../../stripe/services/StripeWebhookService';
|
||||
import type {IUserRepositoryAggregate} from '../../user/repositories/IUserRepositoryAggregate';
|
||||
@@ -80,6 +81,7 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
|
||||
const ageVerificationService = deps.stripe
|
||||
? new AgeVerificationService(deps.stripe, deps.userRepository, deps.gatewayService, deps.cacheService)
|
||||
: null;
|
||||
const refundService = new StripeRefundService(deps.stripe, deps.userRepository, subscriptionService);
|
||||
const webhookService = new StripeWebhookService(
|
||||
deps.stripe,
|
||||
checkoutService,
|
||||
@@ -99,6 +101,7 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
|
||||
deps.adminRepository,
|
||||
deps.snowflakeService,
|
||||
deps.billingRepository,
|
||||
refundService,
|
||||
);
|
||||
await webhookService.handleWebhook({body, signature});
|
||||
};
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {mergeFrozenUnreadOrder} from '@app/features/app/components/floating/UnreadChannelOrder';
|
||||
import styles from '@app/features/app/components/floating/UnreadChannelsContent.module.css';
|
||||
import {
|
||||
BULK_PREVIEW_CHANNEL_BATCH_SIZE,
|
||||
UNREAD_PREVIEW_MESSAGE_LIMIT,
|
||||
} from '@app/features/app/components/floating/UnreadPreviewBudget';
|
||||
import previewStyles from '@app/features/app/components/shared/MessagePreview.module.css';
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import {renderChannelStream} from '@app/features/channel/components/ChannelMessageStream';
|
||||
@@ -131,8 +135,6 @@ interface ChannelPreviewData {
|
||||
|
||||
const INITIAL_VISIBLE_CHANNELS = 10;
|
||||
const LOAD_MORE_CHUNK = 10;
|
||||
const UNREAD_PREVIEW_MESSAGE_LIMIT = 5;
|
||||
const BULK_PREVIEW_CHANNEL_BATCH_SIZE = 10;
|
||||
|
||||
interface CacheEntry {
|
||||
cacheKey: string;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
BULK_PREVIEW_CHANNEL_BATCH_SIZE,
|
||||
UNREAD_PREVIEW_MESSAGE_LIMIT,
|
||||
} from '@app/features/app/components/floating/UnreadPreviewBudget';
|
||||
import {BulkMessageFetchRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const buildBatch = (limit: number) => ({
|
||||
requests: Array.from({length: BULK_PREVIEW_CHANNEL_BATCH_SIZE}, (_, index) => ({
|
||||
channel_id: String(1000000000000000000n + BigInt(index)),
|
||||
limit,
|
||||
})),
|
||||
});
|
||||
|
||||
describe('unread preview fetch budget', () => {
|
||||
it('keeps the anchored window inside the bulk fetch schema', () => {
|
||||
expect(() => BulkMessageFetchRequest.parse(buildBatch(UNREAD_PREVIEW_MESSAGE_LIMIT * 2))).not.toThrow();
|
||||
});
|
||||
|
||||
it('keeps the unanchored window inside the bulk fetch schema', () => {
|
||||
expect(() => BulkMessageFetchRequest.parse(buildBatch(UNREAD_PREVIEW_MESSAGE_LIMIT))).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a batch one channel wider than the client sends', () => {
|
||||
const oversized = buildBatch(UNREAD_PREVIEW_MESSAGE_LIMIT * 2);
|
||||
oversized.requests.push({channel_id: '1000000000000000099', limit: UNREAD_PREVIEW_MESSAGE_LIMIT * 2});
|
||||
expect(() => BulkMessageFetchRequest.parse(oversized)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const UNREAD_PREVIEW_MESSAGE_LIMIT = 25;
|
||||
export const BULK_PREVIEW_CHANNEL_BATCH_SIZE = 5;
|
||||
@@ -19,10 +19,12 @@ export interface PlaceholderMessageGroup {
|
||||
readonly usernameWidth: number;
|
||||
readonly timestampWidth: number;
|
||||
readonly attachment: PlaceholderAttachmentSize | null;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
interface MutablePlaceholderMessageGroup extends PlaceholderMessageGroup {
|
||||
attachment: PlaceholderAttachmentSize | null;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface PlaceholderSpecs {
|
||||
@@ -100,20 +102,20 @@ function generatePlaceholderSpecs(options: PlaceholderGenerationOptions): Placeh
|
||||
for (let line = 0; line < lineCount; line++) {
|
||||
lineWidths.push(LINE_WIDTH_MIN + random() * LINE_WIDTH_RANGE);
|
||||
}
|
||||
const groupHeight = compact
|
||||
? MESSAGE_HEIGHT_COMPACT * lineCount
|
||||
: COZY_LEAD_MESSAGE_HEIGHT + COZY_GROUPED_MESSAGE_HEIGHT * (lineCount - 1);
|
||||
groups.push({
|
||||
lineWidths,
|
||||
usernameWidth: USERNAME_WIDTH_MIN + random() * USERNAME_WIDTH_RANGE,
|
||||
timestampWidth: TIMESTAMP_WIDTH_MIN + random() * TIMESTAMP_WIDTH_RANGE,
|
||||
attachment: null,
|
||||
height: groupHeight,
|
||||
});
|
||||
if (index > 0) {
|
||||
totalHeight += groupSpacing;
|
||||
}
|
||||
if (compact) {
|
||||
totalHeight += MESSAGE_HEIGHT_COMPACT * lineCount;
|
||||
} else {
|
||||
totalHeight += COZY_LEAD_MESSAGE_HEIGHT + COZY_GROUPED_MESSAGE_HEIGHT * (lineCount - 1);
|
||||
}
|
||||
totalHeight += groupHeight;
|
||||
}
|
||||
const availableGroupIndices = Array.from(Array(groups.length).keys());
|
||||
for (let index = 0; index < attachments && availableGroupIndices.length > 0; index++) {
|
||||
@@ -123,6 +125,7 @@ function generatePlaceholderSpecs(options: PlaceholderGenerationOptions): Placeh
|
||||
height: randomInRange(random, ATTACHMENT_HEIGHT_MIN, ATTACHMENT_HEIGHT_MAX),
|
||||
};
|
||||
groups[groupIndex].attachment = attachment;
|
||||
groups[groupIndex].height += attachment.height + ATTACHMENT_MARGIN;
|
||||
totalHeight += attachment.height + ATTACHMENT_MARGIN;
|
||||
}
|
||||
return {compact, compactAvatarsVisible, groups, totalHeight, groupSpacing};
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
.messageGroup {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
.group {
|
||||
|
||||
@@ -52,7 +52,10 @@ function CozyScrollFillerSkeletonGroup({
|
||||
return (
|
||||
<flx-message-list-skeleton-group
|
||||
className={flxElementClassName(styles.messageGroup)}
|
||||
style={{marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing)}}
|
||||
style={{
|
||||
marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing),
|
||||
containIntrinsicSize: `auto ${remFromPx(group.height)}`,
|
||||
}}
|
||||
data-flx="app.skeleton.scroll-filler-skeleton.cozy-scroll-filler-skeleton-group.message-group"
|
||||
>
|
||||
<flx-message-list-skeleton-group-row
|
||||
@@ -170,7 +173,10 @@ function CompactScrollFillerSkeletonGroup({
|
||||
return (
|
||||
<flx-message-list-skeleton-group
|
||||
className={flxElementClassName(styles.messageGroup)}
|
||||
style={{marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing)}}
|
||||
style={{
|
||||
marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing),
|
||||
containIntrinsicSize: `auto ${remFromPx(group.height)}`,
|
||||
}}
|
||||
data-flx="app.skeleton.scroll-filler-skeleton.compact-scroll-filler-skeleton-group.message-group"
|
||||
>
|
||||
<flx-message-list-skeleton-compact-messages
|
||||
|
||||
@@ -16,7 +16,6 @@ class Initialization {
|
||||
state: InitializationState = InitializationState.LOADING;
|
||||
hasCompletedInitialLoad = false;
|
||||
error: string | null = null;
|
||||
readyPayload: unknown = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
@@ -46,29 +45,25 @@ class Initialization {
|
||||
setLoading(): void {
|
||||
this.state = InitializationState.LOADING;
|
||||
this.error = null;
|
||||
this.readyPayload = null;
|
||||
}
|
||||
|
||||
@action
|
||||
setConnecting(): void {
|
||||
this.state = InitializationState.CONNECTING;
|
||||
this.error = null;
|
||||
this.readyPayload = null;
|
||||
}
|
||||
|
||||
@action
|
||||
setReady(payload: unknown): void {
|
||||
setReady(): void {
|
||||
this.state = InitializationState.READY;
|
||||
this.hasCompletedInitialLoad = true;
|
||||
this.error = null;
|
||||
this.readyPayload = payload;
|
||||
}
|
||||
|
||||
@action
|
||||
setError(error: string): void {
|
||||
this.state = InitializationState.ERROR;
|
||||
this.error = error;
|
||||
this.readyPayload = null;
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -76,7 +71,6 @@ class Initialization {
|
||||
this.state = InitializationState.LOADING;
|
||||
this.hasCompletedInitialLoad = false;
|
||||
this.error = null;
|
||||
this.readyPayload = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ function handleReadyInternal(data: ReadyPayload, context: GatewayHandlerContext)
|
||||
GuildReadState.handleConnectionOpen();
|
||||
Presence.handleConnectionOpen(data.user, guilds, data.presences);
|
||||
MediaEngine.handleConnectionOpen(guilds);
|
||||
Initialization.setReady(data);
|
||||
Initialization.setReady();
|
||||
context.setReady();
|
||||
Messages.handleConnectionOpen((channelId) => ReadStates.getUnreadJumpAnchor(channelId));
|
||||
}
|
||||
|
||||
@@ -236,10 +236,33 @@ export function getSoundCaptureMasterGainNode(): GainNode {
|
||||
return getMasterGainNode();
|
||||
}
|
||||
|
||||
const soundCaptureActivationListeners: Set<() => void> = new Set();
|
||||
|
||||
export function isSoundCaptureActive(): boolean {
|
||||
return captureTaps.size > 0;
|
||||
}
|
||||
|
||||
export function onSoundCaptureActivated(listener: () => void): () => void {
|
||||
soundCaptureActivationListeners.add(listener);
|
||||
return () => {
|
||||
soundCaptureActivationListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function addSoundCaptureDestination(node: AudioNode): void {
|
||||
if (captureTaps.has(node)) return;
|
||||
const master = getMasterGainNode();
|
||||
const wasInactive = captureTaps.size === 0;
|
||||
captureTaps.add(node);
|
||||
if (wasInactive) {
|
||||
for (const listener of soundCaptureActivationListeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch (error) {
|
||||
logger.warn('Sound capture activation listener failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
master.connect(node);
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const master = {connect: vi.fn(), disconnect: vi.fn()};
|
||||
const createdGains: Array<{connect: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn>}> = [];
|
||||
let captureActive = true;
|
||||
|
||||
vi.mock('@app/features/notification/utils/SoundUtils', () => ({
|
||||
getSoundCaptureAudioContext: () => ({
|
||||
createMediaElementSource: vi.fn(() => ({connect: vi.fn(), disconnect: vi.fn()})),
|
||||
createGain: vi.fn(() => {
|
||||
const gain = {connect: vi.fn(), disconnect: vi.fn(), gain: {value: 1}};
|
||||
createdGains.push(gain);
|
||||
return gain;
|
||||
}),
|
||||
}),
|
||||
getSoundCaptureMasterGainNode: () => master,
|
||||
isSoundCaptureActive: () => captureActive,
|
||||
onSoundCaptureActivated: () => () => undefined,
|
||||
}));
|
||||
|
||||
const {routeMediaElementForSoundCapture} = await import('@app/features/voice/utils/InAppMediaSoundCapture');
|
||||
|
||||
function createElement(): HTMLMediaElement {
|
||||
const listeners = new Map<string, () => void>();
|
||||
return {
|
||||
volume: 1,
|
||||
muted: false,
|
||||
addEventListener: (type: string, listener: () => void) => listeners.set(type, listener),
|
||||
removeEventListener: (type: string) => listeners.delete(type),
|
||||
listenerCount: () => listeners.size,
|
||||
} as unknown as HTMLMediaElement & {listenerCount: () => number};
|
||||
}
|
||||
|
||||
describe('in-app media sound capture', () => {
|
||||
beforeEach(() => {
|
||||
createdGains.length = 0;
|
||||
captureActive = true;
|
||||
});
|
||||
|
||||
it('disconnects the element from the master graph when the caller disposes', () => {
|
||||
const element = createElement();
|
||||
const dispose = routeMediaElementForSoundCapture(element);
|
||||
const gain = createdGains.at(-1);
|
||||
expect(gain?.connect).toHaveBeenCalledWith(master);
|
||||
dispose();
|
||||
expect(gain?.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes the volume listener when the caller disposes', () => {
|
||||
const element = createElement() as HTMLMediaElement & {listenerCount: () => number};
|
||||
const dispose = routeMediaElementForSoundCapture(element);
|
||||
expect(element.listenerCount()).toBe(1);
|
||||
dispose();
|
||||
expect(element.listenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('reuses the existing source node when the same element is routed again', () => {
|
||||
const element = createElement();
|
||||
routeMediaElementForSoundCapture(element)();
|
||||
const gainsAfterFirst = createdGains.length;
|
||||
const dispose = routeMediaElementForSoundCapture(element);
|
||||
expect(createdGains.length).toBe(gainsAfterFirst);
|
||||
expect(createdGains.at(-1)?.connect).toHaveBeenCalledTimes(2);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,24 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getSoundCaptureAudioContext, getSoundCaptureMasterGainNode} from '@app/features/notification/utils/SoundUtils';
|
||||
import {
|
||||
getSoundCaptureAudioContext,
|
||||
getSoundCaptureMasterGainNode,
|
||||
isSoundCaptureActive,
|
||||
onSoundCaptureActivated,
|
||||
} from '@app/features/notification/utils/SoundUtils';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
|
||||
const logger = new Logger('InAppMediaSoundCapture');
|
||||
|
||||
interface RoutedElement {
|
||||
connected: boolean;
|
||||
source: MediaElementAudioSourceNode;
|
||||
gain: GainNode;
|
||||
volumeListener: () => void;
|
||||
}
|
||||
|
||||
const routedElements: WeakMap<HTMLMediaElement, RoutedElement> = new WeakMap();
|
||||
const pendingElements: Set<WeakRef<HTMLMediaElement>> = new Set();
|
||||
|
||||
function syncGainFromElement(el: HTMLMediaElement, gain: GainNode): void {
|
||||
const volume = el.muted ? 0 : Math.max(0, Math.min(1, el.volume));
|
||||
@@ -20,9 +27,20 @@ function syncGainFromElement(el: HTMLMediaElement, gain: GainNode): void {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function routeMediaElementForSoundCapture(element: HTMLMediaElement): () => void {
|
||||
if (routedElements.has(element)) {
|
||||
return () => undefined;
|
||||
function attachToCaptureGraph(element: HTMLMediaElement): void {
|
||||
const existing = routedElements.get(element);
|
||||
if (existing) {
|
||||
if (existing.connected) return;
|
||||
try {
|
||||
existing.gain.connect(getSoundCaptureMasterGainNode());
|
||||
} catch (error) {
|
||||
logger.debug('Failed to reconnect media element to sound capture graph', {error});
|
||||
return;
|
||||
}
|
||||
element.addEventListener('volumechange', existing.volumeListener);
|
||||
syncGainFromElement(element, existing.gain);
|
||||
existing.connected = true;
|
||||
return;
|
||||
}
|
||||
let ctx: AudioContext;
|
||||
let master: GainNode;
|
||||
@@ -31,7 +49,7 @@ export function routeMediaElementForSoundCapture(element: HTMLMediaElement): ()
|
||||
master = getSoundCaptureMasterGainNode();
|
||||
} catch (error) {
|
||||
logger.debug('Sound capture context unavailable; leaving media element on native playback', {error});
|
||||
return () => undefined;
|
||||
return;
|
||||
}
|
||||
let source: MediaElementAudioSourceNode;
|
||||
let gain: GainNode;
|
||||
@@ -42,23 +60,45 @@ export function routeMediaElementForSoundCapture(element: HTMLMediaElement): ()
|
||||
gain.connect(master);
|
||||
} catch (error) {
|
||||
logger.debug('Failed to route media element through sound capture graph', {error});
|
||||
return () => undefined;
|
||||
return;
|
||||
}
|
||||
syncGainFromElement(element, gain);
|
||||
const volumeListener = (): void => syncGainFromElement(element, gain);
|
||||
element.addEventListener('volumechange', volumeListener);
|
||||
const routed: RoutedElement = {source, gain, volumeListener};
|
||||
routedElements.set(element, routed);
|
||||
routedElements.set(element, {source, gain, volumeListener, connected: true});
|
||||
}
|
||||
|
||||
function detachFromCaptureGraph(element: HTMLMediaElement): void {
|
||||
const entry = routedElements.get(element);
|
||||
if (!entry || !entry.connected) return;
|
||||
entry.connected = false;
|
||||
element.removeEventListener('volumechange', entry.volumeListener);
|
||||
try {
|
||||
entry.gain.disconnect();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function flushPendingElements(): void {
|
||||
for (const ref of pendingElements) {
|
||||
const element = ref.deref();
|
||||
pendingElements.delete(ref);
|
||||
if (element) {
|
||||
attachToCaptureGraph(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onSoundCaptureActivated(flushPendingElements);
|
||||
|
||||
export function routeMediaElementForSoundCapture(element: HTMLMediaElement): () => void {
|
||||
if (isSoundCaptureActive()) {
|
||||
attachToCaptureGraph(element);
|
||||
return () => detachFromCaptureGraph(element);
|
||||
}
|
||||
const ref = new WeakRef(element);
|
||||
pendingElements.add(ref);
|
||||
return () => {
|
||||
const entry = routedElements.get(element);
|
||||
if (!entry || entry !== routed) return;
|
||||
routedElements.delete(element);
|
||||
element.removeEventListener('volumechange', volumeListener);
|
||||
try {
|
||||
gain.disconnect();
|
||||
} catch {}
|
||||
try {
|
||||
source.disconnect();
|
||||
} catch {}
|
||||
pendingElements.delete(ref);
|
||||
detachFromCaptureGraph(element);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ export function startMediaDeviceStartupPreload(): () => void {
|
||||
logger.debug('Failed to preload media devices', {error});
|
||||
});
|
||||
};
|
||||
preloadDevices();
|
||||
const disposePermissionListener = MediaPermission.addChangeListener(preloadDevices);
|
||||
return () => {
|
||||
stopped = true;
|
||||
|
||||
@@ -401,7 +401,7 @@ export type MessagesQuery = z.infer<typeof MessagesQuery>;
|
||||
|
||||
const BulkMessageFetchEntryRequest = z.object({
|
||||
channel_id: SnowflakeType.describe('The ID of the channel to fetch messages from'),
|
||||
limit: z.number().int().min(1).max(25).describe('Number of messages to return for this channel (1-25)'),
|
||||
limit: z.number().int().min(1).max(50).describe('Number of messages to return for this channel (1-50)'),
|
||||
before: SnowflakeType.optional().describe('Get messages before this message ID'),
|
||||
after: SnowflakeType.optional().describe('Get messages after this message ID'),
|
||||
around: SnowflakeType.optional().describe('Get messages around this message ID'),
|
||||
|
||||
@@ -301,13 +301,20 @@ export const SelfServeRefundResponse = z.object({
|
||||
payment_intent_id: z.string().nullable(),
|
||||
charge_id: z.string().nullable(),
|
||||
refund_id: z.string().nullable(),
|
||||
refunded_amount_cents: z.number().int(),
|
||||
refunded_amount_cents: z
|
||||
.number()
|
||||
.int()
|
||||
.describe('Amount actually refunded so far, in the currency minor unit; 0 until the provider confirms success'),
|
||||
invoice_amount_paid_cents: z.number().int(),
|
||||
currency: z.string(),
|
||||
subscription_id: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('Subscription that was cancelled along with the refund, when applicable'),
|
||||
status: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('Provider status of the refund (e.g. pending, succeeded, failed); money only moved once succeeded'),
|
||||
});
|
||||
|
||||
export type SelfServeRefundResponse = z.infer<typeof SelfServeRefundResponse>;
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
/// <reference types="node" />
|
||||
|
||||
import {readdirSync, readFileSync, statSync} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '../../../..');
|
||||
const BLOCKED_IMPORT = '@fluxer/' + 'voice_' + 'engine_contract';
|
||||
const BLOCKED_PATH = 'voice_' + 'engine_contract';
|
||||
const BLOCKED_MARKERS = [
|
||||
BLOCKED_IMPORT,
|
||||
BLOCKED_PATH,
|
||||
'Voice' + 'EngineApi',
|
||||
'VOICE_ENGINE_' + 'CONTRACT_VERSION',
|
||||
'contract' + 'Version',
|
||||
];
|
||||
const SCAN_TARGETS = ['fluxer_app', 'fluxer_desktop', 'packages', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'];
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'.cjs',
|
||||
'.cts',
|
||||
'.js',
|
||||
'.json',
|
||||
'.jsx',
|
||||
'.mjs',
|
||||
'.mts',
|
||||
'.rs',
|
||||
'.ts',
|
||||
'.tsx',
|
||||
'.toml',
|
||||
'.yaml',
|
||||
'.yml',
|
||||
]);
|
||||
const SKIPPED_DIRECTORIES = new Set(['.git', 'coverage', 'dist', 'node_modules', 'target', 'vendor']);
|
||||
|
||||
function shouldScanFile(filePath: string): boolean {
|
||||
if (path.basename(filePath) === 'package.json') return true;
|
||||
return TEXT_EXTENSIONS.has(path.extname(filePath));
|
||||
}
|
||||
|
||||
function collectFiles(targetPath: string, files: Array<string> = []): Array<string> {
|
||||
const stat = statSync(targetPath);
|
||||
if (stat.isFile()) {
|
||||
if (shouldScanFile(targetPath)) files.push(targetPath);
|
||||
return files;
|
||||
}
|
||||
if (!stat.isDirectory()) return files;
|
||||
if (SKIPPED_DIRECTORIES.has(path.basename(targetPath))) return files;
|
||||
for (const entry of readdirSync(targetPath)) {
|
||||
collectFiles(path.join(targetPath, entry), files);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
describe('voice engine v2 package boundary', () => {
|
||||
it('does not reintroduce the deleted v1 voice engine contract', () => {
|
||||
const matches = SCAN_TARGETS.flatMap((target) => collectFiles(path.join(REPO_ROOT, target))).flatMap((filePath) => {
|
||||
const source = readFileSync(filePath, 'utf8');
|
||||
const blockedMarker = BLOCKED_MARKERS.find((marker) => source.includes(marker));
|
||||
if (!blockedMarker) return [];
|
||||
return [`${path.relative(REPO_ROOT, filePath)} -> ${blockedMarker}`];
|
||||
});
|
||||
|
||||
expect(matches).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
/// <reference types="node" />
|
||||
|
||||
import {readdirSync, readFileSync, statSync} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '../../../..');
|
||||
const APP_SOURCE_ROOT = path.join(REPO_ROOT, 'fluxer_app/src');
|
||||
const APP_ALIAS_PREFIX = '@app/';
|
||||
const APP_ALIAS_ROOT = path.join(REPO_ROOT, 'fluxer_app/src');
|
||||
const SCAN_TARGETS = ['fluxer_app/src'];
|
||||
const DELETED_APP_VOICE_ENGINE_MODULE_BASENAMES = [
|
||||
'Audio',
|
||||
'Connection',
|
||||
'DebugLogging',
|
||||
'EntranceSound',
|
||||
'Media',
|
||||
'MediaStateCoordinator',
|
||||
'Participant',
|
||||
'Permission',
|
||||
'RemoteSpeakingLevel',
|
||||
'ScreenShare',
|
||||
'State',
|
||||
'StateSync',
|
||||
'Stats',
|
||||
'Subscription',
|
||||
];
|
||||
const APP_VOICE_ENGINE_MODULE_PREFIX = 'fluxer_app/src/features/voice/engine/Voice';
|
||||
const APP_VOICE_ENGINE_DELETED_MODULE_SUFFIX = 'Manager';
|
||||
|
||||
const DELETED_APP_VOICE_ENGINE_MODULES = new Set(
|
||||
DELETED_APP_VOICE_ENGINE_MODULE_BASENAMES.map(
|
||||
(basename) => `${APP_VOICE_ENGINE_MODULE_PREFIX}${basename}${APP_VOICE_ENGINE_DELETED_MODULE_SUFFIX}`,
|
||||
),
|
||||
);
|
||||
|
||||
const DELETED_APP_VOICE_ENGINE_IMPORT_ALLOWLIST: Array<string> = [];
|
||||
|
||||
const IMPORT_FROM_PATTERN = /\b(?:import|export)\s+(?:type\s+)?[^'";]*?\sfrom\s*['"]([^'"]+)['"]/g;
|
||||
const SIDE_EFFECT_IMPORT_PATTERN = /\bimport\s*['"]([^'"]+)['"]/g;
|
||||
const DYNAMIC_IMPORT_PATTERN = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx']);
|
||||
const SKIPPED_DIRECTORIES = new Set(['.git', 'coverage', 'dist', 'node_modules']);
|
||||
|
||||
function toRepoPath(filePath: string): string {
|
||||
return path.relative(REPO_ROOT, filePath).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function withoutSourceExtension(filePath: string): string {
|
||||
return filePath.replace(/\.(?:c|m)?tsx?$/, '');
|
||||
}
|
||||
|
||||
function shouldScanFile(filePath: string): boolean {
|
||||
const basename = path.basename(filePath);
|
||||
if (basename.endsWith('.d.ts')) return false;
|
||||
if (basename.endsWith('.bench.ts')) return false;
|
||||
if (/\.(?:test|spec)\.tsx?$/.test(basename)) return false;
|
||||
return SOURCE_EXTENSIONS.has(path.extname(filePath));
|
||||
}
|
||||
|
||||
function collectFiles(targetPath: string, files: Array<string> = []): Array<string> {
|
||||
const stat = statSync(targetPath);
|
||||
if (stat.isFile()) {
|
||||
if (shouldScanFile(targetPath)) files.push(targetPath);
|
||||
return files;
|
||||
}
|
||||
if (!stat.isDirectory()) return files;
|
||||
if (SKIPPED_DIRECTORIES.has(path.basename(targetPath))) return files;
|
||||
for (const entry of readdirSync(targetPath).sort()) {
|
||||
collectFiles(path.join(targetPath, entry), files);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function collectImportSpecifiers(source: string): Array<string> {
|
||||
const specifiers = new Set<string>();
|
||||
for (const pattern of [IMPORT_FROM_PATTERN, SIDE_EFFECT_IMPORT_PATTERN, DYNAMIC_IMPORT_PATTERN]) {
|
||||
pattern.lastIndex = 0;
|
||||
let match = pattern.exec(source);
|
||||
while (match) {
|
||||
specifiers.add(match[1]);
|
||||
match = pattern.exec(source);
|
||||
}
|
||||
}
|
||||
return [...specifiers];
|
||||
}
|
||||
|
||||
function resolveImportTarget(sourceFile: string, specifier: string): string | null {
|
||||
if (specifier.startsWith(APP_ALIAS_PREFIX)) {
|
||||
const aliasedPath = path.join(APP_ALIAS_ROOT, specifier.slice(APP_ALIAS_PREFIX.length));
|
||||
return toRepoPath(withoutSourceExtension(aliasedPath));
|
||||
}
|
||||
|
||||
if (specifier.startsWith('.')) {
|
||||
const relativePath = path.resolve(path.dirname(sourceFile), specifier);
|
||||
if (!relativePath.startsWith(APP_SOURCE_ROOT)) return null;
|
||||
return toRepoPath(withoutSourceExtension(relativePath));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectDeletedAppVoiceEngineImports(): Array<string> {
|
||||
const imports = new Set<string>();
|
||||
for (const target of SCAN_TARGETS) {
|
||||
for (const filePath of collectFiles(path.join(REPO_ROOT, target))) {
|
||||
const source = readFileSync(filePath, 'utf8');
|
||||
for (const specifier of collectImportSpecifiers(source)) {
|
||||
const resolvedTarget = resolveImportTarget(filePath, specifier);
|
||||
if (!resolvedTarget || !DELETED_APP_VOICE_ENGINE_MODULES.has(resolvedTarget)) continue;
|
||||
imports.add(`${toRepoPath(filePath)} -> ${resolvedTarget}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...imports].sort();
|
||||
}
|
||||
|
||||
describe('deleted app voice engine import guard', () => {
|
||||
it('blocks production imports from deleted app-owned voice engine modules', () => {
|
||||
const actualImports = collectDeletedAppVoiceEngineImports();
|
||||
|
||||
expect(actualImports).toHaveLength(DELETED_APP_VOICE_ENGINE_IMPORT_ALLOWLIST.length);
|
||||
expect(actualImports).toEqual(DELETED_APP_VOICE_ENGINE_IMPORT_ALLOWLIST);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user