Skip to content

Commit

Permalink
local mock testing and loading module
Browse files Browse the repository at this point in the history
  • Loading branch information
elliotBraem committed Jan 31, 2025
1 parent b51fc59 commit a86c07f
Show file tree
Hide file tree
Showing 12 changed files with 570 additions and 105 deletions.
127 changes: 113 additions & 14 deletions backend/src/__tests__/mocks/twitter-service.mock.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,52 @@
import { Tweet } from "agent-twitter-client";
import { TwitterService } from "../../services/twitter/client";
import { logger } from "../../utils/logger";

export class MockTwitterService {
export class MockTwitterService extends TwitterService {
private mockTweets: Tweet[] = [];
private mockUserIds: Map<string, string> = new Map();
private lastCheckedTweetId: string | null = null;
private tweetIdCounter: bigint = BigInt(Date.now());

public addMockTweet(tweet: Tweet) {
this.mockTweets.push(tweet);
constructor() {
// Pass empty config since we're mocking
super({
username: "mock_user",
password: "mock_pass",
email: "[email protected]",
});
// Override the client with a basic mock
(this as any).client = {
isLoggedIn: async () => true,
login: async () => {},
logout: async () => {},
getCookies: async () => [],
setCookies: async () => {},
};
}

private getNextTweetId(): string {
this.tweetIdCounter = this.tweetIdCounter + BigInt(1);
return this.tweetIdCounter.toString();
}

public addMockTweet(tweet: Partial<Tweet> & { inReplyToStatusId?: string }) {
const fullTweet: Tweet = {
id: this.getNextTweetId(),
text: tweet.text || "",
username: tweet.username || "test_user",
userId: tweet.userId || `mock-user-id-${tweet.username || "test_user"}`,
timeParsed: tweet.timeParsed || new Date(),
hashtags: tweet.hashtags || [],
mentions: tweet.mentions || [],
photos: tweet.photos || [],
urls: tweet.urls || [],
videos: tweet.videos || [],
thread: [],
inReplyToStatusId: tweet.inReplyToStatusId,
};
this.mockTweets.push(fullTweet);
logger.info(`Mock: Added tweet "${fullTweet.text}" from @${fullTweet.username}${tweet.inReplyToStatusId ? ` as reply to ${tweet.inReplyToStatusId}` : ''}`);
return fullTweet;
}

public addMockUserId(username: string, userId: string) {
Expand All @@ -15,41 +55,100 @@ export class MockTwitterService {

public clearMockTweets() {
this.mockTweets = [];
logger.info("Mock: Cleared all tweets");
}

async initialize(): Promise<void> {
// No-op for mock
logger.info("Mock Twitter service initialized");
}

async stop(): Promise<void> {
// No-op for mock
logger.info("Mock Twitter service stopped");
}

async getUserIdByScreenName(screenName: string): Promise<string> {
return this.mockUserIds.get(screenName) || `mock-user-id-${screenName}`;
}

async fetchAllNewMentions(): Promise<Tweet[]> {
return this.mockTweets;
// Get the last tweet ID we processed
const lastCheckedId = this.getLastCheckedTweetId();

// If we have tweets and no last checked ID, set it to the newest tweet
if (this.mockTweets.length > 0 && !lastCheckedId) {
const newestTweet = this.mockTweets[this.mockTweets.length - 1];
await this.setLastCheckedTweetId(newestTweet.id);
return [newestTweet];
}

// Filter tweets newer than last checked ID
const newTweets = this.mockTweets.filter(tweet => {
if (!lastCheckedId) return true;
return BigInt(tweet.id) > BigInt(lastCheckedId);
});

// Update last checked ID if we found new tweets
if (newTweets.length > 0) {
const newestTweet = newTweets[newTweets.length - 1];
await this.setLastCheckedTweetId(newestTweet.id);
}

return newTweets;
}

async getTweet(tweetId: string): Promise<Tweet | null> {
return this.mockTweets.find((t) => t.id === tweetId) || null;
}

async replyToTweet(tweetId: string, message: string): Promise<string | null> {
return `mock-reply-${Date.now()}`;
const replyTweet = await this.addMockTweet({
text: message,
username: "curatedotfun",
inReplyToStatusId: tweetId,
});
logger.info(`Mock: Replied to tweet ${tweetId} with "${message}"`);
return replyTweet.id;
}

async setLastCheckedTweetId(tweetId: string): Promise<void> {
this.lastCheckedTweetId = tweetId;
async likeTweet(tweetId: string): Promise<void> {
logger.info(`Mock: Liked tweet ${tweetId}`);
}

async likeTweet(tweetId: string): Promise<void> {
return;
// Helper methods for test scenarios
async simulateSubmission(contentUrl: string) {
// First create the content tweet
const contentTweet = await this.addMockTweet({
text: "Original content",
username: "content_creator",
});

// Then create the curator's submission as a reply
return this.addMockTweet({
text: `@curatedotfun !submit ${contentUrl}`,
username: "curator",
userId: "mock-user-id-curator",
timeParsed: new Date(),
inReplyToStatusId: contentTweet.id,
});
}

async simulateApprove(submissionTweetId: string, projectId: string) {
return this.addMockTweet({
text: `@curatedotfun !approve ${projectId}`,
username: "moderator",
userId: "mock-user-id-moderator",
timeParsed: new Date(),
inReplyToStatusId: submissionTweetId,
});
}

getLastCheckedTweetId(): string | null {
return this.lastCheckedTweetId;
async simulateReject(submissionTweetId: string, projectId: string, reason: string) {
return this.addMockTweet({
text: `@curatedotfun !reject ${projectId} ${reason}`,
username: "moderator",
userId: "mock-user-id-moderator",
timeParsed: new Date(),
inReplyToStatusId: submissionTweetId,
});
}
}
52 changes: 22 additions & 30 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { db } from "./services/db";
import { DistributionService } from "./services/distribution/distribution.service";
import { SubmissionService } from "./services/submissions/submission.service";
import { TwitterService } from "./services/twitter/client";
import { testRoutes, mockTwitterService } from "./routes/test";
import {
cleanup,
failSpinner,
Expand Down Expand Up @@ -50,19 +51,25 @@ export async function main() {
await distributionService.initialize(config.plugins);
succeedSpinner("distribution-init", "distribution service initialized");

// Try to initialize Twitter service, but continue if it fails
// Use mock service in development, real service in production
try {
startSpinner("twitter-init", "Initializing Twitter service...");
twitterService = new TwitterService({
username: process.env.TWITTER_USERNAME!,
password: process.env.TWITTER_PASSWORD!,
email: process.env.TWITTER_EMAIL!,
twoFactorSecret: process.env.TWITTER_2FA_SECRET,
});
await twitterService.initialize();
if (process.env.NODE_ENV === "development") {
logger.info("Using mock Twitter service");
twitterService = mockTwitterService;
await twitterService.initialize();
} else {
twitterService = new TwitterService({
username: process.env.TWITTER_USERNAME!,
password: process.env.TWITTER_PASSWORD!,
email: process.env.TWITTER_EMAIL!,
twoFactorSecret: process.env.TWITTER_2FA_SECRET,
});
await twitterService.initialize();
}
succeedSpinner("twitter-init", "Twitter service initialized");

// Only initialize submission service if Twitter is available
// Initialize submission service
startSpinner("submission-init", "Initializing submission service...");
submissionService = new SubmissionService(
twitterService,
Expand All @@ -86,12 +93,12 @@ export async function main() {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"], // Allow images from HTTPS sources
fontSrc: ["'self'", "data:", "https:"], // Allow fonts
imgSrc: ["'self'", "data:", "https:"],
fontSrc: ["'self'", "data:", "https:"],
},
},
crossOriginEmbedderPolicy: false, // Required for some static assets
crossOriginResourcePolicy: { policy: "cross-origin" }, // Allow resources to be shared
crossOriginEmbedderPolicy: false,
crossOriginResourcePolicy: { policy: "cross-origin" },
xFrameOptions: { action: "sameorigin" },
}),
)
Expand All @@ -102,6 +109,8 @@ export async function main() {
}),
)
.use(swagger())
// Include test routes in development
.use(process.env.NODE_ENV === "development" ? testRoutes : new Elysia())
.get("/health", () => new Response("OK", { status: 200 }))
// API Routes
.get("/api/twitter/last-tweet-id", () => {
Expand Down Expand Up @@ -180,23 +189,6 @@ export async function main() {
const rawConfig = await configService.getRawConfig();
return rawConfig.feeds;
})
// .post("/api/twitter/cookies", async ({ body }: { body: TwitterCookie[] }) => {
// if (!twitterService) {
// throw new Error("Twitter service not available");
// }
// if (!Array.isArray(body)) {
// throw new Error("Expected array of cookies");
// }
// await twitterService.setCookies(body);
// return { success: true };
// })
// .get("/api/twitter/cookies", () => {
// if (!twitterService) {
// throw new Error("Twitter service not available");
// }
// const cookies = twitterService.getCookies();
// return cookies || [];
// })
.get(
"/api/config/:feedId",
({ params: { feedId } }: { params: { feedId: string } }) => {
Expand Down
59 changes: 59 additions & 0 deletions backend/src/routes/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Elysia } from "elysia";
import { MockTwitterService } from "../__tests__/mocks/twitter-service.mock";
import { Tweet } from "agent-twitter-client";

// Create a single mock instance to maintain state
const mockTwitterService = new MockTwitterService();

// Helper to create a tweet object
const createTweet = (text: string, username: string): Tweet => ({
id: Date.now().toString(),
text,
username,
userId: `mock-user-id-${username}`,
timeParsed: new Date(),
hashtags: [],
mentions: [],
photos: [],
urls: [],
videos: [],
thread: [],
});

export const testRoutes = new Elysia({ prefix: "/api/test" })
.guard({
beforeHandle: ({ request }) => {
// Only allow in development and test environments
if (process.env.NODE_ENV === "production") {
return new Response("Not found", { status: 404 });
}
},
})
.get("/tweets", () => {
return mockTwitterService.fetchAllNewMentions();
})
.post("/tweets", async ({ body }) => {
const { text, username } = body as { text: string; username: string };
const tweet = createTweet(text, username);
mockTwitterService.addMockTweet(tweet);
return tweet;
})
.post("/reset", () => {
mockTwitterService.clearMockTweets();
return { success: true };
})
.post("/scenario/approve", async ({ body }) => {
const { projectId } = body as { projectId: string };
const tweet = createTweet(`@curatedotfun approve ${projectId}`, "curator");
mockTwitterService.addMockTweet(tweet);
return { success: true, tweet };
})
.post("/scenario/reject", async ({ body }) => {
const { projectId, reason } = body as { projectId: string; reason: string };
const tweet = createTweet(`@curatedotfun reject ${projectId} ${reason}`, "curator");
mockTwitterService.addMockTweet(tweet);
return { success: true, tweet };
});

// Export for use in tests and for replacing the real service
export { mockTwitterService };
11 changes: 9 additions & 2 deletions backend/src/services/config/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,22 @@ import fs from "fs/promises";
import path from "path";
import { AppConfig } from "../../types/config";
import { hydrateConfigValues } from "../../utils/config";
import { logger } from "../../utils/logger";

export class ConfigService {
private static instance: ConfigService;
private config: AppConfig | null = null;
private configPath: string;

private constructor() {
// Always look for config relative to the distribution directory
this.configPath = path.resolve(__dirname, "../../../../curate.config.json");
// Use test config in development mode
if (process.env.NODE_ENV === "development") {
this.configPath = path.resolve(__dirname, "../../../../curate.config.test.json");
logger.info("Using test configuration");
} else {
this.configPath = path.resolve(__dirname, "../../../../curate.config.json");
logger.info("Using production configuration");
}
}

public static getInstance(): ConfigService {
Expand Down
6 changes: 3 additions & 3 deletions backend/src/services/twitter/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ export class TwitterService {
if (await this.client.isLoggedIn()) {
// Cache the new cookies
const cookies = await this.client.getCookies();
const formattedCookies = cookies.map((cookie) => ({
const formattedCookies: TwitterCookie[] = cookies.map((cookie) => ({
name: cookie.key,
value: cookie.value,
domain: cookie.domain,
path: cookie.path,
domain: cookie.domain || ".twitter.com", // Provide default if null
path: cookie.path || "/", // Provide default if null
secure: cookie.secure,
httpOnly: cookie.httpOnly,
sameSite: cookie.sameSite as "Strict" | "Lax" | "None" | undefined,
Expand Down
Binary file modified bun.lockb
Binary file not shown.
4 changes: 4 additions & 0 deletions curate.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
"@curatedotfun/gpt-transform": {
"type": "transformer",
"url": "./external/gpt-transform"
},
"@curatedotfun/supabase": {
"type": "distributor",
"url": "https://unpkg.com/@curatedotfun/supabase@latest/dist/index.js"
}
},
"feeds": [
Expand Down
Loading

0 comments on commit a86c07f

Please sign in to comment.