Skip to content
Open
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
137 changes: 137 additions & 0 deletions packages/apps-engine/tests/server/accessors/SchedulerModify.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { AsyncTest, Expect, SetupFixture } from 'alsatian';

import type { IOnetimeSchedule, IRecurringSchedule } from '../../../src/definition/scheduler';
import { SchedulerModify } from '../../../src/server/accessors';
import type { SchedulerBridge } from '../../../src/server/bridges';

export class SchedulerModifyAccessorTestFixture {
private mockSchedulerBridge: SchedulerBridge;

private scheduleOnceCalls: Array<{ job: IOnetimeSchedule; appId: string }>;

private scheduleRecurringCalls: Array<{ job: IRecurringSchedule; appId: string }>;

private cancelJobCalls: Array<{ jobId: string; appId: string }>;

private cancelAllJobsCalls: string[];

@SetupFixture
public setupFixture(): void {
this.scheduleOnceCalls = [];
this.scheduleRecurringCalls = [];
this.cancelJobCalls = [];
this.cancelAllJobsCalls = [];

const { scheduleOnceCalls, scheduleRecurringCalls, cancelJobCalls, cancelAllJobsCalls } = this;

this.mockSchedulerBridge = {
doScheduleOnce(job: IOnetimeSchedule, appId: string): Promise<void> {
scheduleOnceCalls.push({ job, appId });
return Promise.resolve();
},
doScheduleRecurring(job: IRecurringSchedule, appId: string): Promise<void> {
scheduleRecurringCalls.push({ job, appId });
return Promise.resolve();
},
doCancelJob(jobId: string, appId: string): Promise<void> {
cancelJobCalls.push({ jobId, appId });
return Promise.resolve();
},
doCancelAllJobs(appId: string): Promise<void> {
cancelAllJobsCalls.push(appId);
return Promise.resolve();
},
} as SchedulerBridge;
}

@AsyncTest()
public async scheduleOnceAppendsAppIdWhenMissing(): Promise<void> {
const scheduler = new SchedulerModify(this.mockSchedulerBridge, 'test-app');
const job: IOnetimeSchedule = {
id: 'job-1',
when: new Date('2026-03-24T12:00:00.000Z'),
data: { pollId: '123' },
};

await Expect(() => scheduler.scheduleOnce(job)).not.toThrowAsync();

Expect(this.scheduleOnceCalls.length).toBe(1);
Expect(this.scheduleOnceCalls[0].appId).toBe('test-app');
Expect(this.scheduleOnceCalls[0].job).toEqual({
...job,
id: 'job-1_test-app',
});
Expect(job.id).toBe('job-1');
}

@AsyncTest()
public async scheduleOnceDoesNotAppendAppIdTwice(): Promise<void> {
const scheduler = new SchedulerModify(this.mockSchedulerBridge, 'test-app');
const job: IOnetimeSchedule = {
id: 'job-1_test-app',
when: 'in 5 minutes',
};

await scheduler.scheduleOnce(job);

Expect(this.scheduleOnceCalls.length).toBe(1);
Expect(this.scheduleOnceCalls[0].job.id).toBe('job-1_test-app');
}

@AsyncTest()
public async scheduleRecurringAppendsAppIdWhenMissing(): Promise<void> {
const scheduler = new SchedulerModify(this.mockSchedulerBridge, 'test-app');
const job: IRecurringSchedule = {
id: 'recurring-1',
interval: '5 minutes',
skipImmediate: true,
data: { roomId: 'abc' },
};

await scheduler.scheduleRecurring(job);

Expect(this.scheduleRecurringCalls.length).toBe(1);
Expect(this.scheduleRecurringCalls[0].appId).toBe('test-app');
Expect(this.scheduleRecurringCalls[0].job).toEqual({
...job,
id: 'recurring-1_test-app',
});
Expect(job.id).toBe('recurring-1');
}

@AsyncTest()
public async cancelJobAppendsAppIdWhenMissing(): Promise<void> {
const scheduler = new SchedulerModify(this.mockSchedulerBridge, 'test-app');

await scheduler.cancelJob('cleanup-job');

Expect(this.cancelJobCalls.length).toBe(1);
Expect(this.cancelJobCalls[0]).toEqual({
jobId: 'cleanup-job_test-app',
appId: 'test-app',
});
}

@AsyncTest()
public async cancelJobDoesNotAppendAppIdTwice(): Promise<void> {
const scheduler = new SchedulerModify(this.mockSchedulerBridge, 'test-app');

await scheduler.cancelJob('cleanup-job_test-app');

Expect(this.cancelJobCalls.length).toBe(1);
Expect(this.cancelJobCalls[0]).toEqual({
jobId: 'cleanup-job_test-app',
appId: 'test-app',
});
}

@AsyncTest()
public async cancelAllJobsUsesOnlyAppId(): Promise<void> {
const scheduler = new SchedulerModify(this.mockSchedulerBridge, 'test-app');

await scheduler.cancelAllJobs();

Expect(this.cancelAllJobsCalls.length).toBe(1);
Expect(this.cancelAllJobsCalls[0]).toBe('test-app');
}
}
1 change: 1 addition & 0 deletions packages/message-parser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@types/jest": "~30.0.0",
"@types/node": "~22.16.5",
"eslint": "~9.39.3",
"fast-check": "^4.6.0",
"jest": "~30.2.0",
"npm-run-all": "^4.1.5",
"peggy": "4.1.1",
Expand Down
11 changes: 10 additions & 1 deletion packages/message-parser/src/grammar.pegjs
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,16 @@ InlineEmoji = & { return !skipInlineEmoji; } emo:Emoji { return emo; }

InlineEmoticon = & { return !skipInlineEmoji; } emo:Emoticon & (EmoticonNeighbor / InlineItemPattern) { skipInlineEmoji = false; return emo; }

EscapedTimestampRules
= "\\" "<t:" rawDate:$(Unixtime / ISO8601Date / ISO8601DateWithoutMilliseconds / Timestamp) ":" format:TimestampType ">" {
return plain(`<t:${rawDate}:${format}>`);
}
/ "\\" "<t:" rawDate:$(Unixtime / ISO8601Date / ISO8601DateWithoutMilliseconds / Timestamp) ">" {
return plain(`<t:${rawDate}>`);
}

InlineItemPattern = Whitespace
/ EscapedTimestampRules
/ TimestampRules
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/ MaybeReferences
/ AutolinkedPhone
Expand Down Expand Up @@ -568,7 +577,7 @@ BoldEmoticon = & { return !skipBoldEmoji; } emo:Emoticon & (EmoticonNeighbor / B
/* Strike */
Strikethrough = [\x7E] [\x7E] @StrikethroughContent [\x7E] [\x7E] / [\x7E] @StrikethroughContent [\x7E]

StrikethroughContent = text:(TimestampRules / Whitespace / InlineCode / MaybeReferences / UserMention / ChannelMention / MaybeItalic / MaybeBold / Emoji / Emoticon / AnyStrike / Line)+ {
StrikethroughContent = text:(EscapedTimestampRules / TimestampRules / Whitespace / InlineCode / MaybeReferences / UserMention / ChannelMention / MaybeItalic / MaybeBold / Emoji / Emoticon / AnyStrike / Line)+ {
return strike(reducePlainTexts(text));
}

Expand Down
1 change: 1 addition & 0 deletions packages/message-parser/tests/escaped.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ test.each([
['\\# not a heading', [paragraph([plain('# not a heading')])]],
['\\[foo]: /url "not a reference"', [paragraph([plain('\\[foo]: /url "not a reference"')])]],
['\\&ouml; not a character entity', [paragraph([plain('\\&ouml; not a character entity')])]],
['\\<t:1708551317:R>', [paragraph([plain('<t:1708551317:R>')])]],
])('parses %p', (input, output) => {
expect(parse(input)).toMatchObject(output);
});
60 changes: 60 additions & 0 deletions packages/message-parser/tests/fuzz.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import fc from 'fast-check';

import { parse } from '../src';

const plainTextArbitrary = fc
.stringMatching(/[a-zA-Z0-9 ]{1,20}/)
.filter((value) => value.length > 0);

const timestampArbitrary = fc
.integer({ min: 1_000_000_000, max: 2_000_000_000 })
.map((value) => `<t:${value}>`);

const relativeTimestampArbitrary = fc
.integer({ min: 1_000_000_000, max: 2_000_000_000 })
.map((value) => `<t:${value}:R>`);

const mentionArbitrary = fc
.stringMatching(/[a-z0-9._-]{1,12}/)
.map((value) => `@${value}`);

const boldArbitrary = plainTextArbitrary.map((value) => `*${value}*`);

const strikeArbitrary = fc.oneof(timestampArbitrary, relativeTimestampArbitrary, plainTextArbitrary).map((value) => `~${value}~`);

const inlineCodeArbitrary = plainTextArbitrary.map((value) => `\`${value}\``);

const parserInputArbitrary = fc
.array(
fc.oneof(
plainTextArbitrary,
timestampArbitrary,
relativeTimestampArbitrary,
mentionArbitrary,
boldArbitrary,
strikeArbitrary,
inlineCodeArbitrary,
),
{ minLength: 1, maxLength: 8 },
)
.map((parts) => parts.join(' '));

describe('parser fuzz tests', () => {
test('parses valid parser-like inputs without throwing', () => {
fc.assert(
fc.property(parserInputArbitrary, (input) => {
expect(() => parse(input)).not.toThrow();
}),
{ numRuns: 300 },
);
});

test('is deterministic for valid parser-like inputs', () => {
fc.assert(
fc.property(parserInputArbitrary, (input) => {
expect(parse(input)).toEqual(parse(input));
}),
{ numRuns: 300 },
);
});
});
1 change: 1 addition & 0 deletions packages/message-parser/tests/timestamp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ test.each([

test.each([
['~<t:1708551317>~', [paragraph([strike([timestampNode('1708551317')])])]],
['~<t:1708551317:R>~', [paragraph([strike([timestampNode('1708551317', 'R')])])]],
['*<t:1708551317>*', [paragraph([bold([plain('<t:1708551317>')])])]],
])('parses %p', (input, output) => {
expect(parse(input)).toMatchObject(output);
Expand Down
17 changes: 17 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -9713,6 +9713,7 @@ __metadata:
"@types/jest": "npm:~30.0.0"
"@types/node": "npm:~22.16.5"
eslint: "npm:~9.39.3"
fast-check: "npm:^4.6.0"
jest: "npm:~30.2.0"
npm-run-all: "npm:^4.1.5"
peggy: "npm:4.1.1"
Expand Down Expand Up @@ -22511,6 +22512,15 @@ __metadata:
languageName: node
linkType: hard

"fast-check@npm:^4.6.0":
version: 4.6.0
resolution: "fast-check@npm:4.6.0"
dependencies:
pure-rand: "npm:^8.0.0"
checksum: 10/b53b88ebf6247ea0eaa7f3f6d8923035e0cfd6f78bc8c393754cc1ccbdb591756dfbff02058a136b472e011279594fa72c05a30378177d6055a08ad7ed1b823e
languageName: node
linkType: hard

"fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3":
version: 3.1.3
resolution: "fast-deep-equal@npm:3.1.3"
Expand Down Expand Up @@ -32090,6 +32100,13 @@ __metadata:
languageName: node
linkType: hard

"pure-rand@npm:^8.0.0":
version: 8.0.0
resolution: "pure-rand@npm:8.0.0"
checksum: 10/a5c84320f7337a6984e45cba969c804797535bb03b850897f0ee367d2d531feb489df220d475acc1dcf925c0832ed19a11c2928a94e9197ece5ad003368cb9c7
languageName: node
linkType: hard

"pvtsutils@npm:^1.3.6":
version: 1.3.6
resolution: "pvtsutils@npm:1.3.6"
Expand Down