Skip to content
Merged
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
6 changes: 4 additions & 2 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ on:
- "apps/expo/vitest.config.ts"
- "apps/expo/utils/**"
- "apps/expo/lib/utils/**"
- "apps/expo/features/packs/utils/**"
- "apps/expo/features/**/utils/**"
- ".github/workflows/unit-tests.yml"
pull_request:
branches: ["**"]
paths:
Expand All @@ -26,7 +27,8 @@ on:
- "apps/expo/vitest.config.ts"
- "apps/expo/utils/**"
- "apps/expo/lib/utils/**"
- "apps/expo/features/packs/utils/**"
- "apps/expo/features/**/utils/**"
- ".github/workflows/unit-tests.yml"

permissions:
contents: read
Expand Down
2 changes: 1 addition & 1 deletion apps/expo/app/(app)/(tabs)/(home)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { assertIsString } from '@packrat/guards';
import type { LargeTitleSearchBarMethods, ListDataItem } from '@packrat/ui/nativewindui';
import {
LargeTitleHeader,
Expand Down Expand Up @@ -35,7 +36,6 @@ import { cn } from 'expo-app/lib/cn';
import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
import { asNonNullableRef } from 'expo-app/lib/utils/asNonNullableRef';
import { assertIsString } from 'expo-app/utils/typeAssertions';
import { Link } from 'expo-router';
import { useMemo, useRef, useState } from 'react';
import { FlatList, Platform, Pressable, Text, View } from 'react-native';
Expand Down
2 changes: 1 addition & 1 deletion apps/expo/features/feed/components/CommentItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const CommentItem: React.FC<CommentItemProps> = ({
</TouchableOpacity>
{isOwner && onDelete && (
<TouchableOpacity onPress={() => onDelete(comment.id)}>
<Icon name="delete" size={16} color={colors.grey2} />
<Icon name="trash-can-outline" size={16} color={colors.grey2} />
</TouchableOpacity>
)}
</View>
Expand Down
2 changes: 1 addition & 1 deletion apps/expo/features/feed/components/PostCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const PostCard: React.FC<PostCardProps> = ({ post, onLike, onDelete, curr
</View>
{isOwner && onDelete && (
<TouchableOpacity onPress={() => onDelete(post.id)} hitSlop={8}>
<Icon name="delete" size={20} color={colors.grey2} />
<Icon name="trash-can-outline" size={20} color={colors.grey2} />
</TouchableOpacity>
)}
</View>
Expand Down
124 changes: 124 additions & 0 deletions apps/expo/features/feed/utils/__tests__/feedUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from 'vitest';

// Mock clientEnvs before importing the module under test so that
// buildPostImageUrl has a deterministic CDN base URL.
vi.mock('expo-app/env/clientEnvs', () => ({
clientEnvs: {
EXPO_PUBLIC_R2_PUBLIC_URL: 'https://cdn.example.com',
},
}));

// Also mock getRelativeTime so that formatRelativeDate has a predictable
// alias target that does not depend on the current clock.
vi.mock('expo-app/lib/utils/getRelativeTime', () => ({
getRelativeTime: (input: string | Date) => `relative(${String(input)})`,
}));

import type { Comment, Post } from '../../types';
import { buildPostImageUrl, formatAuthorName, formatRelativeDate } from '../index';

const basePost: Post = {
id: 1,
userId: 42,
caption: 'hello',
images: [],
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
likeCount: 0,
commentCount: 0,
likedByMe: false,
};

const baseComment: Comment = {
id: 1,
postId: 1,
userId: 42,
content: 'nice',
parentCommentId: null,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
likeCount: 0,
likedByMe: false,
};

describe('feed/utils', () => {
// ---------------------------------------------------------------------------
// buildPostImageUrl
// ---------------------------------------------------------------------------
describe('buildPostImageUrl', () => {
it('joins the CDN base URL with the image key', () => {
expect(buildPostImageUrl('posts/abc.jpg')).toBe('https://cdn.example.com/posts/abc.jpg');
});

it('does not double-encode or trim the image key', () => {
expect(buildPostImageUrl('folder/sub folder/image (1).png')).toBe(
'https://cdn.example.com/folder/sub folder/image (1).png',
);
});

it('handles an empty image key by returning the base URL with a trailing slash', () => {
expect(buildPostImageUrl('')).toBe('https://cdn.example.com/');
});
});

// ---------------------------------------------------------------------------
// formatAuthorName
// ---------------------------------------------------------------------------
describe('formatAuthorName', () => {
it('returns "Unknown" when the author is missing', () => {
expect(formatAuthorName(basePost)).toBe('Unknown');
expect(formatAuthorName(baseComment)).toBe('Unknown');
});

it('returns "first last" when both names are present', () => {
const post: Post = {
...basePost,
author: { id: 1, firstName: 'Ada', lastName: 'Lovelace' },
};
expect(formatAuthorName(post)).toBe('Ada Lovelace');
});

it('returns just the first name when only firstName is present', () => {
const post: Post = {
...basePost,
author: { id: 1, firstName: 'Ada', lastName: null },
};
expect(formatAuthorName(post)).toBe('Ada');
});

it('returns just the last name when only lastName is present', () => {
const post: Post = {
...basePost,
author: { id: 1, firstName: null, lastName: 'Lovelace' },
};
expect(formatAuthorName(post)).toBe('Lovelace');
});

it('returns "User" when the author exists but both names are null', () => {
const post: Post = {
...basePost,
author: { id: 1, firstName: null, lastName: null },
};
expect(formatAuthorName(post)).toBe('User');
});

it('also works for Comment entities', () => {
const comment: Comment = {
...baseComment,
author: { id: 1, firstName: 'Grace', lastName: 'Hopper' },
};
expect(formatAuthorName(comment)).toBe('Grace Hopper');
});
});

// ---------------------------------------------------------------------------
// formatRelativeDate (deprecated alias of getRelativeTime)
// ---------------------------------------------------------------------------
describe('formatRelativeDate', () => {
it('delegates to getRelativeTime', () => {
expect(formatRelativeDate('2024-01-01T00:00:00.000Z')).toBe(
'relative(2024-01-01T00:00:00.000Z)',
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { describe, expect, it, vi } from 'vitest';
import type { PackTemplate, PackTemplateItem } from '../../types';

// The SUT imports convertFromGrams/convertToGrams from the packs/utils barrel,
// which also re-exports uploadImage (expo-file-system) and computeCategories
// (expo-app/features/auth/store). Replace the barrel with the real pure
// conversion helpers so the test stays in a Node environment.
vi.mock('expo-app/features/packs/utils', async () => {
const fromGrams = await import('expo-app/features/packs/utils/convertFromGrams');
const toGrams = await import('expo-app/features/packs/utils/convertToGrams');
return {
convertFromGrams: fromGrams.convertFromGrams,
convertToGrams: toGrams.convertToGrams,
};
});

import { computePackTemplateWeights } from '../computePacktemplateWeight';

type TemplateInput = Omit<PackTemplate, 'baseWeight' | 'totalWeight'>;

function makeItem(overrides: Partial<PackTemplateItem> = {}): PackTemplateItem {
return {
id: 'item-1',
packTemplateId: 'tpl-1',
name: 'Item',
weight: 100,
weightUnit: 'g',
quantity: 1,
category: 'gear',
consumable: false,
worn: false,
deleted: false,
...overrides,
};
}

function makeTemplate(items: PackTemplateItem[]): TemplateInput {
return {
id: 'tpl-1',
name: 'Template',
category: 'hiking',
isAppTemplate: false,
items,
deleted: false,
localCreatedAt: '2024-01-01T00:00:00.000Z',
localUpdatedAt: '2024-01-01T00:00:00.000Z',
};
}

describe('computePackTemplateWeights', () => {
// ---------------------------------------------------------------------------
// Empty templates
// ---------------------------------------------------------------------------
describe('with no items', () => {
it('returns 0 for base and total weight (default unit: grams)', () => {
const result = computePackTemplateWeights(makeTemplate([]));
expect(result.baseWeight).toBe(0);
expect(result.totalWeight).toBe(0);
});

it('returns 0 regardless of preferred unit', () => {
const result = computePackTemplateWeights(makeTemplate([]), 'kg');
expect(result.baseWeight).toBe(0);
expect(result.totalWeight).toBe(0);
});
});

// ---------------------------------------------------------------------------
// Non-consumable / non-worn gear (counts toward base and total)
// ---------------------------------------------------------------------------
describe('with base gear only', () => {
it('sums weights into base and total in grams by default', () => {
const result = computePackTemplateWeights(
makeTemplate([
makeItem({ id: 'a', weight: 500, weightUnit: 'g', quantity: 1 }),
makeItem({ id: 'b', weight: 1, weightUnit: 'kg', quantity: 2 }),
]),
);
// 500g + 2 * 1000g = 2500g
expect(result.baseWeight).toBe(2500);
expect(result.totalWeight).toBe(2500);
});

it('converts to the preferred unit (kg)', () => {
const result = computePackTemplateWeights(
makeTemplate([makeItem({ weight: 2500, weightUnit: 'g', quantity: 1 })]),
'kg',
);
// 2500g => 2.5kg, rounded to 2 decimals
expect(result.baseWeight).toBe(2.5);
expect(result.totalWeight).toBe(2.5);
});
});

// ---------------------------------------------------------------------------
// Consumable items (counted in total, not base)
// ---------------------------------------------------------------------------
describe('with consumable items', () => {
it('excludes consumables from base but includes them in total', () => {
const result = computePackTemplateWeights(
makeTemplate([
makeItem({ id: 'a', weight: 1000, weightUnit: 'g', consumable: false }),
makeItem({ id: 'b', weight: 500, weightUnit: 'g', consumable: true }),
]),
);
expect(result.baseWeight).toBe(1000);
expect(result.totalWeight).toBe(1500);
});
});

// ---------------------------------------------------------------------------
// Worn items (counted in total, not base)
// ---------------------------------------------------------------------------
describe('with worn items', () => {
it('excludes worn items from base but includes them in total', () => {
const result = computePackTemplateWeights(
makeTemplate([
makeItem({ id: 'a', weight: 800, weightUnit: 'g', worn: false }),
makeItem({ id: 'b', weight: 200, weightUnit: 'g', worn: true }),
]),
);
expect(result.baseWeight).toBe(800);
expect(result.totalWeight).toBe(1000);
});
});

// ---------------------------------------------------------------------------
// Quantity multiplies the item weight
// ---------------------------------------------------------------------------
describe('quantity handling', () => {
it('multiplies item weight by quantity', () => {
const result = computePackTemplateWeights(
makeTemplate([makeItem({ weight: 250, weightUnit: 'g', quantity: 4 })]),
);
expect(result.totalWeight).toBe(1000);
expect(result.baseWeight).toBe(1000);
});
});

// ---------------------------------------------------------------------------
// Preserves template metadata
// ---------------------------------------------------------------------------
it('returns the same template metadata alongside computed weights', () => {
const template = makeTemplate([makeItem({ weight: 100, weightUnit: 'g', quantity: 1 })]);
const result = computePackTemplateWeights(template);
expect(result.id).toBe(template.id);
expect(result.name).toBe(template.name);
expect(result.items).toHaveLength(1);
});
});
3 changes: 2 additions & 1 deletion packages/analytics/src/core/catalog-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
* which resolves to the Iceberg table via `USE packrat.default`.
*/

import type { DuckDBConnection } from '@duckdb/node-api';
import type { DuckDBConnection, DuckDBInstance } from '@duckdb/node-api';
import consola from 'consola';
import { createCatalogConnection } from './connection';
import { LocalCacheManager } from './local-cache';

export class CatalogCacheManager extends LocalCacheManager {
private catalogInstance: DuckDBInstance | null = null;
private catalogConn: DuckDBConnection | null = null;

constructor() {
Expand Down
3 changes: 2 additions & 1 deletion packages/api/src/db/schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type InferInsertModel, type InferSelectModel, relations, sql } from 'drizzle-orm';
import {
type AnyPgColumn,
boolean,
index,
integer,
Expand Down Expand Up @@ -607,7 +608,7 @@ export const postComments = pgTable('post_comments', {
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
content: text('content').notNull(),
parentCommentId: integer('parent_comment_id').references(() => postComments.id, {
parentCommentId: integer('parent_comment_id').references((): AnyPgColumn => postComments.id, {
onDelete: 'cascade',
}),
createdAt: timestamp('created_at').defaultNow().notNull(),
Expand Down
4 changes: 4 additions & 0 deletions packages/api/src/routes/feed/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ const addCommentRoute = createRoute({
description: 'Comment created successfully',
content: { 'application/json': { schema: CommentSchema } },
},
400: {
description: 'Failed to create comment',
content: { 'application/json': { schema: ErrorResponseSchema } },
},
Comment on lines +138 to +141
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The route now documents a 400 response, but the handler’s only 400 path (if (!newComment)) is unlikely to ever occur—Drizzle will typically throw on insert failures (e.g. FK violation when parentCommentId doesn’t exist), which would bubble as a 500. Consider validating parentCommentId (exists and belongs to the same postId) before inserting and/or catching insert errors to return a deterministic 400/404 that matches the OpenAPI contract.

Copilot uses AI. Check for mistakes.
404: {
description: 'Post not found',
content: { 'application/json': { schema: ErrorResponseSchema } },
Expand Down
5 changes: 4 additions & 1 deletion packages/api/src/routes/wildlife/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,10 @@ wildlifeRoutes.openapi(identifyRoute, async (c) => {
Bucket: PACKRAT_BUCKET_R2_BUCKET_NAME,
Key: image,
});
const imageUrl = await getPresignedUrl(c, command, { expiresIn: 3600 });
const imageUrl = await getPresignedUrl(c, {
command,
signOptions: { expiresIn: 3600 },
});

const service = new WildlifeIdentificationService(c);
let identification: Awaited<ReturnType<typeof service.identifySpecies>>;
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/schemas/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export const CommentSchema = z
export const CreateCommentRequestSchema = z
.object({
content: z.string().min(1).max(1000).openapi({ example: 'Looks amazing!' }),
parentCommentId: z.number().int().optional().openapi({ example: null }),
parentCommentId: z.number().int().optional().openapi({ example: undefined }),
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

openapi({ example: undefined }) is not a valid OpenAPI/JSON example value (and can be confusing to readers). Prefer either omitting the example field entirely for this optional property, or using a representative integer example (e.g. 123) and describing that the field should be omitted for top-level comments.

Suggested change
parentCommentId: z.number().int().optional().openapi({ example: undefined }),
parentCommentId: z.number().int().optional(),

Copilot uses AI. Check for mistakes.
})
.openapi('CreateCommentRequest');

Expand Down
Loading