diff --git a/internal/storage/s3/s3_test.go b/internal/storage/s3/s3_test.go new file mode 100644 index 00000000..ea399f08 --- /dev/null +++ b/internal/storage/s3/s3_test.go @@ -0,0 +1,52 @@ +package s3 + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + storepb "github.com/usememos/memos/proto/gen/store" +) + +func TestNewClientInsecureSkipTLSVerify(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "/memos/note.txt", r.URL.Path) + _, err := w.Write([]byte("stored memo")) + require.NoError(t, err) + })) + defer server.Close() + + newConfig := func(skipVerify bool) *storepb.StorageS3Config { + return &storepb.StorageS3Config{ + AccessKeyId: "access-key", + AccessKeySecret: "access-secret", + Endpoint: server.URL, + Region: "us-east-1", + Bucket: "memos", + UsePathStyle: true, + InsecureSkipTlsVerify: skipVerify, + } + } + + t.Run("rejects self-signed certificate by default", func(t *testing.T) { + client, err := NewClient(context.Background(), newConfig(false)) + require.NoError(t, err) + + _, err = client.GetObject(context.Background(), "note.txt") + require.Error(t, err) + require.ErrorContains(t, err, "certificate") + }) + + t.Run("accepts self-signed certificate when enabled", func(t *testing.T) { + client, err := NewClient(context.Background(), newConfig(true)) + require.NoError(t, err) + + content, err := client.GetObject(context.Background(), "note.txt") + require.NoError(t, err) + require.Equal(t, []byte("stored memo"), content) + }) +} diff --git a/scripts/entrypoint_test.go b/scripts/entrypoint_test.go new file mode 100644 index 00000000..e2924ead --- /dev/null +++ b/scripts/entrypoint_test.go @@ -0,0 +1,46 @@ +package scripts + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestEntrypointDoesNotLoopWhenTargetUIDIsRoot(t *testing.T) { + mockBin := t.TempDir() + writeExecutable(t, filepath.Join(mockBin, "id"), "#!/bin/sh\nprintf '0\\n'\n") + writeExecutable(t, filepath.Join(mockBin, "chown"), "#!/bin/sh\nexit 0\n") + writeExecutable(t, filepath.Join(mockBin, "su-exec"), "#!/bin/sh\nshift\nexec \"$@\"\n") + + _, currentFile, _, ok := runtime.Caller(0) + require.True(t, ok) + entrypoint := filepath.Join(filepath.Dir(currentFile), "entrypoint.sh") + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "sh", entrypoint, "sh", "-c", "printf 'started-once'") + cmd.Env = append(os.Environ(), + "PATH="+mockBin+string(os.PathListSeparator)+os.Getenv("PATH"), + "MEMOS_UID=0", + "MEMOS_GID=0", + ) + + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + require.NotEqual(t, context.DeadlineExceeded, ctx.Err(), "entrypoint recursed instead of launching the command") + require.Contains(t, string(output), "memos: starting as UID:GID 0:0") + require.Equal(t, 1, strings.Count(string(output), "memos: starting as UID:GID 0:0")) + require.Contains(t, string(output), "started-once") +} + +func writeExecutable(t *testing.T, path, content string) { + t.Helper() + require.NoError(t, os.WriteFile(path, []byte(content), 0755)) +} diff --git a/server/router/api/v1/test/instance_service_test.go b/server/router/api/v1/test/instance_service_test.go index 7f4849c9..fa425d7b 100644 --- a/server/router/api/v1/test/instance_service_test.go +++ b/server/router/api/v1/test/instance_service_test.go @@ -711,11 +711,12 @@ func TestUpdateInstanceSetting(t *testing.T) { Value: &v1pb.InstanceSetting_StorageSetting_{ StorageSetting: &v1pb.InstanceSetting_StorageSetting{ S3Config: &v1pb.InstanceSetting_StorageSetting_S3Config{ - AccessKeyId: "AKID", - AccessKeySecret: "super-secret", - Endpoint: "s3.example.com", - Region: "us-east-1", - Bucket: "memos", + AccessKeyId: "AKID", + AccessKeySecret: "super-secret", + Endpoint: "s3.example.com", + Region: "us-east-1", + Bucket: "memos", + InsecureSkipTlsVerify: true, }, }, }, @@ -730,6 +731,8 @@ func TestUpdateInstanceSetting(t *testing.T) { require.NoError(t, err) require.Empty(t, resp.GetStorageSetting().GetS3Config().GetAccessKeySecret(), "AccessKeySecret must never be returned in responses") + require.True(t, resp.GetStorageSetting().GetS3Config().GetInsecureSkipTlsVerify(), + "insecure_skip_tls_verify must round-trip through the API") // Update with empty secret; original must be preserved in the store. _, err = ts.Service.UpdateInstanceSetting(adminCtx, &v1pb.UpdateInstanceSettingRequest{ @@ -738,11 +741,12 @@ func TestUpdateInstanceSetting(t *testing.T) { Value: &v1pb.InstanceSetting_StorageSetting_{ StorageSetting: &v1pb.InstanceSetting_StorageSetting{ S3Config: &v1pb.InstanceSetting_StorageSetting_S3Config{ - AccessKeyId: "AKID", - AccessKeySecret: "", // omitted / not changed - Endpoint: "s3-v2.example.com", - Region: "us-east-1", - Bucket: "memos", + AccessKeyId: "AKID", + AccessKeySecret: "", // omitted / not changed + Endpoint: "s3-v2.example.com", + Region: "us-east-1", + Bucket: "memos", + InsecureSkipTlsVerify: true, }, }, }, @@ -755,6 +759,7 @@ func TestUpdateInstanceSetting(t *testing.T) { require.Equal(t, "super-secret", stored.GetS3Config().GetAccessKeySecret(), "existing AccessKeySecret must be preserved when an empty value is sent") require.Equal(t, "s3-v2.example.com", stored.GetS3Config().GetEndpoint()) + require.True(t, stored.GetS3Config().GetInsecureSkipTlsVerify()) }) t.Run("UpdateInstanceSetting - AI provider keys are write-only and preserved on empty", func(t *testing.T) { diff --git a/server/router/api/v1/test/personal_access_token_test.go b/server/router/api/v1/test/personal_access_token_test.go new file mode 100644 index 00000000..be7bd58c --- /dev/null +++ b/server/router/api/v1/test/personal_access_token_test.go @@ -0,0 +1,51 @@ +package test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + v1pb "github.com/usememos/memos/proto/gen/api/v1" +) + +func TestCreatePersonalAccessTokenExpiration(t *testing.T) { + ctx := context.Background() + ts := NewTestService(t) + defer ts.Cleanup() + + user, err := ts.CreateRegularUser(ctx, "pat-expiration") + require.NoError(t, err) + userCtx := ts.CreateUserContext(ctx, user.ID) + parent := "users/" + user.Username + + t.Run("zero days never expires", func(t *testing.T) { + response, err := ts.Service.CreatePersonalAccessToken(userCtx, &v1pb.CreatePersonalAccessTokenRequest{ + Parent: parent, + Description: "non-expiring token", + }) + require.NoError(t, err) + require.True(t, strings.HasPrefix(response.Token, "memos_pat_")) + require.Nil(t, response.PersonalAccessToken.ExpiresAt) + + listed, err := ts.Service.ListPersonalAccessTokens(userCtx, &v1pb.ListPersonalAccessTokensRequest{Parent: parent}) + require.NoError(t, err) + require.Len(t, listed.PersonalAccessTokens, 1) + require.Nil(t, listed.PersonalAccessTokens[0].ExpiresAt) + }) + + t.Run("positive days sets expiration", func(t *testing.T) { + before := time.Now().Add(29 * 24 * time.Hour) + response, err := ts.Service.CreatePersonalAccessToken(userCtx, &v1pb.CreatePersonalAccessTokenRequest{ + Parent: parent, + Description: "expiring token", + ExpiresInDays: 30, + }) + require.NoError(t, err) + require.NotNil(t, response.PersonalAccessToken.ExpiresAt) + require.True(t, response.PersonalAccessToken.ExpiresAt.AsTime().After(before)) + require.True(t, response.PersonalAccessToken.ExpiresAt.AsTime().Before(time.Now().Add(31*24*time.Hour))) + }) +} diff --git a/store/demo_seed_test.go b/store/demo_seed_test.go index ea60240c..1ef2a080 100644 --- a/store/demo_seed_test.go +++ b/store/demo_seed_test.go @@ -3,6 +3,7 @@ package store_test import ( "context" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" @@ -62,4 +63,42 @@ func TestDemoSeedUsesDeploymentAuthenticationPolicy(t *testing.T) { require.NotNil(t, aliceUser) require.Error(t, bcrypt.CompareHashAndPassword([]byte(aliceUser.PasswordHash), []byte("demo"))) require.NotEqual(t, adminUser.PasswordHash, aliceUser.PasswordHash) + + memos, err := stores.ListMemos(ctx, &store.FindMemo{}) + require.NoError(t, err) + require.GreaterOrEqual(t, len(memos), 20) + + var hasPublic, hasProtected, hasLocation, hasNestedTag, hasMention bool + for _, memo := range memos { + hasPublic = hasPublic || memo.Visibility == store.Public + hasProtected = hasProtected || memo.Visibility == store.Protected + hasLocation = hasLocation || memo.Payload.GetLocation() != nil + for _, tag := range memo.Payload.GetTags() { + hasNestedTag = hasNestedTag || strings.Contains(tag, "/") + } + hasMention = hasMention || strings.Contains(memo.Content, "@steven") + } + require.True(t, hasPublic, "demo should include public memos") + require.True(t, hasProtected, "demo should include protected memos") + require.True(t, hasLocation, "demo should include location metadata") + require.True(t, hasNestedTag, "demo should include nested tags") + require.True(t, hasMention, "demo should include a mention") + + relations, err := stores.ListMemoRelations(ctx, &store.FindMemoRelation{}) + require.NoError(t, err) + var hasComment, hasReference bool + for _, relation := range relations { + hasComment = hasComment || relation.Type == store.MemoRelationComment + hasReference = hasReference || relation.Type == store.MemoRelationReference + } + require.True(t, hasComment, "demo should include comments") + require.True(t, hasReference, "demo should include memo references") + + reactions, err := stores.ListReactions(ctx, &store.FindReaction{}) + require.NoError(t, err) + require.NotEmpty(t, reactions, "demo should include reactions") + + attachments, err := stores.ListAttachments(ctx, &store.FindAttachment{HasRelatedMemo: true}) + require.NoError(t, err) + require.NotEmpty(t, attachments, "demo should include a memo attachment") } diff --git a/web/tests/about-page.test.tsx b/web/tests/about-page.test.tsx index 3394a59f..a0af2ce3 100644 --- a/web/tests/about-page.test.tsx +++ b/web/tests/about-page.test.tsx @@ -62,6 +62,7 @@ describe("", () => { expect(screen.getByRole("link", { name: /about\.official-website/ })).toHaveAttribute("href", "https://usememos.com/"); expect(screen.getByRole("link", { name: /about\.documents/ })).toHaveAttribute("href", "https://usememos.com/docs"); expect(screen.getByRole("link", { name: /API Docs/ })).toHaveAttribute("href", "https://usememos.com/docs/api"); + expect(screen.getByRole("link", { name: /Web Clipper/ })).toHaveAttribute("href", "https://github.com/usememos/web-clipper"); expect(screen.getByRole("link", { name: /about\.github-repository/ })).toHaveAttribute("href", "https://github.com/usememos/memos"); }); diff --git a/web/tests/create-access-token-dialog.test.tsx b/web/tests/create-access-token-dialog.test.tsx new file mode 100644 index 00000000..7db1b025 --- /dev/null +++ b/web/tests/create-access-token-dialog.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import CreateAccessTokenDialog from "@/components/CreateAccessTokenDialog"; + +const createPersonalAccessToken = vi.hoisted(() => vi.fn()); + +vi.mock("@/connect", () => ({ + userServiceClient: { + createPersonalAccessToken, + }, +})); + +vi.mock("@/hooks/useCurrentUser", () => ({ + default: () => ({ name: "users/alice" }), +})); + +vi.mock("@/utils/i18n", () => ({ + useTranslate: () => (key: string) => key, +})); + +describe("CreateAccessTokenDialog", () => { + beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); + Element.prototype.hasPointerCapture = vi.fn(() => false); + Element.prototype.setPointerCapture = vi.fn(); + Element.prototype.releasePointerCapture = vi.fn(); + }); + + beforeEach(() => { + createPersonalAccessToken.mockReset(); + createPersonalAccessToken.mockResolvedValue({ token: "" }); + }); + + it("creates a non-expiring token by default", async () => { + render(); + + expect(screen.getByRole("radio", { name: "setting.access-token.create-dialog.duration-never" })).toBeChecked(); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "CLI token" } }); + fireEvent.click(screen.getByRole("button", { name: "common.create" })); + + await waitFor(() => + expect(createPersonalAccessToken).toHaveBeenCalledWith({ + parent: "users/alice", + description: "CLI token", + expiresInDays: 0, + }), + ); + }); +}); diff --git a/web/tests/create-webhook-dialog.test.tsx b/web/tests/create-webhook-dialog.test.tsx new file mode 100644 index 00000000..b54cc662 --- /dev/null +++ b/web/tests/create-webhook-dialog.test.tsx @@ -0,0 +1,88 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import CreateWebhookDialog from "@/components/CreateWebhookDialog"; + +const clients = vi.hoisted(() => ({ + createUserWebhook: vi.fn(), + getUserWebhookSigningSecret: vi.fn(), + listUserWebhooks: vi.fn(), + updateUserWebhook: vi.fn(), +})); + +vi.mock("@/connect", () => ({ + userServiceClient: clients, +})); + +vi.mock("@/hooks/useCurrentUser", () => ({ + default: () => ({ name: "users/alice" }), +})); + +vi.mock("@/utils/i18n", () => ({ + useTranslate: () => (key: string) => key, +})); + +describe("CreateWebhookDialog", () => { + beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); + Element.prototype.hasPointerCapture = vi.fn(() => false); + Element.prototype.setPointerCapture = vi.fn(); + Element.prototype.releasePointerCapture = vi.fn(); + }); + + beforeEach(() => { + for (const client of Object.values(clients)) { + client.mockReset(); + } + }); + + it("reveals the server-generated secret after creating a webhook", async () => { + clients.createUserWebhook.mockResolvedValue({ name: "users/alice/webhooks/deploy", signingSecretSet: true }); + clients.getUserWebhookSigningSecret.mockResolvedValue({ signingSecret: "whsec_created-secret" }); + const onOpenChange = vi.fn(); + const onSuccess = vi.fn(); + + render(); + + fireEvent.change(screen.getByLabelText(/setting\.webhook\.create-dialog\.title/), { target: { value: "Deploy" } }); + fireEvent.change(screen.getByLabelText(/setting\.webhook\.create-dialog\.payload-url/), { + target: { value: "https://example.com/webhook" }, + }); + fireEvent.click(screen.getByRole("button", { name: "common.create" })); + + await waitFor(() => + expect(clients.createUserWebhook).toHaveBeenCalledWith({ + parent: "users/alice", + webhook: { displayName: "Deploy", url: "https://example.com/webhook" }, + }), + ); + expect(clients.getUserWebhookSigningSecret).toHaveBeenCalledWith({ name: "users/alice/webhooks/deploy" }); + expect(await screen.findByDisplayValue("whsec_created-secret")).toBeInTheDocument(); + expect(onSuccess).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "common.close" })); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("shows configured status and reveals an existing secret while editing", async () => { + clients.listUserWebhooks.mockResolvedValue({ + webhooks: [ + { + name: "users/alice/webhooks/deploy", + displayName: "Deploy", + url: "https://example.com/webhook", + signingSecretSet: true, + }, + ], + }); + clients.getUserWebhookSigningSecret.mockResolvedValue({ signingSecret: "whsec_existing-secret" }); + + render(); + + expect(await screen.findByText("setting.webhook.create-dialog.signing-secret-configured")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /setting\.webhook\.create-dialog\.reveal-secret/ })); + + expect(await screen.findByDisplayValue("whsec_existing-secret")).toBeInTheDocument(); + expect(clients.getUserWebhookSigningSecret).toHaveBeenCalledWith({ name: "users/alice/webhooks/deploy" }); + }); +}); diff --git a/web/tests/editor-formatting.test.ts b/web/tests/editor-formatting.test.ts index 9970b7b2..17677419 100644 --- a/web/tests/editor-formatting.test.ts +++ b/web/tests/editor-formatting.test.ts @@ -1,12 +1,13 @@ import { markdown } from "@codemirror/lang-markdown"; import { EditorSelection, EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; +import { GFM } from "@lezer/markdown"; import { describe, expect, it } from "vitest"; import { createFormattingController } from "@/components/MemoEditor/Editor/formatting"; function setup(doc: string, from: number, to: number) { const view = new EditorView({ - state: EditorState.create({ doc, extensions: [markdown()], selection: EditorSelection.range(from, to) }), + state: EditorState.create({ doc, extensions: [markdown({ extensions: [GFM] })], selection: EditorSelection.range(from, to) }), }); return { view, f: createFormattingController(view, new Set()) }; } @@ -53,4 +54,28 @@ describe("formatting controller", () => { f.run("code"); expect(view.state.doc.toString()).toBe("code here"); }); + + it("toggles strikethrough and reports it active", () => { + const { view, f } = setup("obsolete text", 0, 8); + + f.run("strikethrough"); + + expect(view.state.doc.toString()).toBe("~~obsolete~~ text"); + expect(f.getActiveFormats().strikethrough).toBe(true); + + f.run("strikethrough"); + expect(view.state.doc.toString()).toBe("obsolete text"); + }); + + it("toggles a fenced code block and reports it active", () => { + const { view, f } = setup("first\nsecond", 0, 12); + + f.run("codeBlock"); + + expect(view.state.doc.toString()).toBe("```\nfirst\nsecond\n```"); + expect(f.getActiveFormats().codeBlock).toBe(true); + + f.run("codeBlock"); + expect(view.state.doc.toString()).toBe("first\nsecond"); + }); }); diff --git a/web/tests/location-display-view.test.tsx b/web/tests/location-display-view.test.tsx new file mode 100644 index 00000000..43c80e20 --- /dev/null +++ b/web/tests/location-display-view.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import LocationDisplayView from "@/components/MemoMetadata/Location/LocationDisplayView"; +import type { Location } from "@/types/proto/api/v1/memo_service_pb"; + +vi.mock("@/components/map/LazyLocationPicker", () => ({ + LazyLocationPicker: () =>
, +})); + +describe("LocationDisplayView", () => { + it("truncates the visible address while preserving the full value as a title", () => { + const placeholder = "A very long street address that should not force the memo metadata row wider than its container"; + const location = { latitude: 1.3521, longitude: 103.8198, placeholder } as Location; + + render(); + + const trigger = screen.getByRole("button", { name: new RegExp(placeholder) }); + const visibleAddress = screen.getByText(placeholder); + expect(trigger).toHaveAttribute("title", placeholder); + expect(trigger).toHaveClass("max-w-full", "min-w-0"); + expect(visibleAddress).toHaveClass("min-w-0", "truncate"); + }); +}); diff --git a/web/tests/memo-comments-pagination.test.tsx b/web/tests/memo-comments-pagination.test.tsx new file mode 100644 index 00000000..a743586b --- /dev/null +++ b/web/tests/memo-comments-pagination.test.tsx @@ -0,0 +1,58 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const listMemoComments = vi.hoisted(() => vi.fn()); + +vi.mock("@/connect", () => ({ + memoServiceClient: { + listMemoComments, + }, +})); + +import { useInfiniteMemoComments } from "@/hooks/useMemoQueries"; + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe("useInfiniteMemoComments", () => { + beforeEach(() => { + listMemoComments.mockReset(); + }); + + it("follows nextPageToken and flattens every fetched comment page", async () => { + listMemoComments + .mockResolvedValueOnce({ + memos: [{ name: "memos/comment-1" }, { name: "memos/comment-2" }], + nextPageToken: "page-2", + }) + .mockResolvedValueOnce({ + memos: [{ name: "memos/comment-3" }], + nextPageToken: "", + }); + + const { result } = renderHook(() => useInfiniteMemoComments("memos/parent", { pageSize: 2 }), { wrapper }); + + await waitFor(() => expect(result.current.data?.map((memo) => memo.name)).toEqual(["memos/comment-1", "memos/comment-2"])); + expect(listMemoComments).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ name: "memos/parent", pageSize: 2, pageToken: "" }), + ); + expect(result.current.hasNextPage).toBe(true); + + await act(async () => { + await result.current.fetchNextPage(); + }); + + await waitFor(() => + expect(result.current.data?.map((memo) => memo.name)).toEqual(["memos/comment-1", "memos/comment-2", "memos/comment-3"]), + ); + expect(listMemoComments).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ name: "memos/parent", pageSize: 2, pageToken: "page-2" }), + ); + expect(result.current.hasNextPage).toBe(false); + }); +}); diff --git a/web/tests/memo-content-footnotes.test.tsx b/web/tests/memo-content-footnotes.test.tsx new file mode 100644 index 00000000..e8cbe918 --- /dev/null +++ b/web/tests/memo-content-footnotes.test.tsx @@ -0,0 +1,72 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { MemoMarkdownRendererCore } from "@/components/MemoContent/MemoMarkdownRenderer"; + +const FOOTNOTE_MARKDOWN = "A statement with a note.[^1]\n\n[^1]: The footnote body."; + +const LocationProbe = () => { + const location = useLocation(); + return {`${location.pathname}${location.hash}`}; +}; + +const renderFootnote = (compact = false) => + render( + +
+ +
+ +
, + ); + +describe("memo footnotes", () => { + beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); + if (!globalThis.CSS) { + Object.defineProperty(globalThis, "CSS", { configurable: true, value: {} }); + } + if (!globalThis.CSS.escape) { + globalThis.CSS.escape = (value: string) => value; + } + }); + + it("keeps GFM footnote references aligned with their sanitized target ids", () => { + const { container } = renderFootnote(); + + const reference = container.querySelector("a[data-footnote-ref]"); + const target = container.querySelector("#user-content-fn-1"); + + expect(reference).not.toBeNull(); + expect(reference).toHaveAttribute("href", "/memos/abc123#user-content-fn-1"); + expect(target).not.toBeNull(); + expect(container.querySelector("#user-content-user-content-fn-1")).toBeNull(); + }); + + it("scrolls to a footnote inside the same fully rendered memo", () => { + const { container } = renderFootnote(); + const reference = container.querySelector("a[data-footnote-ref]"); + const target = container.querySelector("#user-content-fn-1"); + const scrollIntoView = vi.fn(); + target!.scrollIntoView = scrollIntoView; + + fireEvent.click(reference!); + + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" }); + expect(screen.getByTestId("location")).toHaveTextContent("/"); + }); + + it("navigates compact cards to the memo detail footnote", () => { + const { container } = renderFootnote(true); + const reference = container.querySelector("a[data-footnote-ref]"); + + fireEvent.click(reference!); + + expect(screen.getByTestId("location")).toHaveTextContent("/memos/abc123#user-content-fn-1"); + }); +}); diff --git a/web/tests/memo-detail-sidebar.test.tsx b/web/tests/memo-detail-sidebar.test.tsx new file mode 100644 index 00000000..e35626f9 --- /dev/null +++ b/web/tests/memo-detail-sidebar.test.tsx @@ -0,0 +1,107 @@ +import { create } from "@bufbuild/protobuf"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import MemoDetailSidebar from "@/components/MemoDetailSidebar/MemoDetailSidebar"; +import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb"; +import { State } from "@/types/proto/api/v1/common_pb"; +import { LocationSchema, MemoSchema, Visibility } from "@/types/proto/api/v1/memo_service_pb"; + +const updateMemo = vi.hoisted(() => vi.fn()); + +vi.mock("copy-to-clipboard", () => ({ default: vi.fn() })); +vi.mock("react-hot-toast", () => ({ default: { success: vi.fn() } })); +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children, render }: { children: React.ReactNode; render: React.ReactElement }) => ( + <> + {render} + {children} + + ), +})); +vi.mock("@/components/MemoEditor/Toolbar/VisibilitySelector", () => ({ + default: ({ onChange }: { onChange: (visibility: Visibility) => void }) => ( + + ), +})); +vi.mock("@/components/UserAvatar", () => ({ default: () =>
})); +vi.mock("@/components/MemoDetailSidebar/MemoOutline", () => ({ + default: ({ headings }: { headings: unknown[] }) =>
{headings.length}
, +})); +vi.mock("@/components/MemoDetailSidebar/MemoSharePanel", () => ({ default: () =>
})); +vi.mock("@/components/MemoMetadata/Relation/useResolvedRelationMemos", () => ({ useResolvedRelationMemos: () => ({}) })); +vi.mock("@/contexts/InstanceContext", () => ({ useInstance: () => ({ profile: { instanceUrl: "https://memos.example" } }) })); +vi.mock("@/hooks/useCurrentUser", () => ({ default: () => ({ name: "users/alice" }) })); +vi.mock("@/hooks/useMemoQueries", () => ({ useUpdateMemo: () => ({ mutateAsync: updateMemo }) })); +vi.mock("@/hooks/useUserQueries", () => ({ + useUser: () => ({ data: { username: "alice", displayName: "Alice", avatarUrl: "" } }), +})); +vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key })); +vi.mock("@/i18n", () => ({ default: { language: "en-US" } })); + +describe("MemoDetailSidebar", () => { + beforeEach(() => { + updateMemo.mockReset(); + updateMemo.mockResolvedValue(undefined); + }); + + it("renders the property rail and wires editable quick actions", async () => { + const memo = create(MemoSchema, { + name: "memos/detail", + creator: "users/alice", + state: State.NORMAL, + visibility: Visibility.PUBLIC, + content: "# Overview\n\n## Details", + tags: ["release/0.30"], + location: create(LocationSchema, { placeholder: "Singapore" }), + attachments: [ + create(AttachmentSchema, { + name: "attachments/spec", + filename: "release-notes.pdf", + type: "application/pdf", + size: 2048n, + }), + ], + }); + const onShareImageOpen = vi.fn(); + + render( + + + , + ); + + expect(screen.getByText("common.visibility")).toBeInTheDocument(); + expect(screen.getByText("common.created-at")).toBeInTheDocument(); + expect(screen.getByText("common.author")).toBeInTheDocument(); + expect(screen.getByText("common.location")).toBeInTheDocument(); + expect(screen.getByText("common.tags")).toBeInTheDocument(); + expect(screen.getByText("common.attachments")).toBeInTheDocument(); + expect(screen.getByText("memo.outline")).toBeInTheDocument(); + expect(screen.getByText("release/0.30")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /release-notes\.pdf/ })).toBeInTheDocument(); + expect(screen.getByTestId("outline")).toHaveTextContent("2"); + + fireEvent.click(screen.getByRole("button", { name: "common.pin" })); + await waitFor(() => + expect(updateMemo).toHaveBeenCalledWith({ update: { name: "memos/detail", pinned: true }, updateMask: ["pinned"] }), + ); + + fireEvent.click(screen.getByRole("button", { name: "visibility-selector" })); + await waitFor(() => + expect(updateMemo).toHaveBeenCalledWith({ + update: { name: "memos/detail", visibility: Visibility.PRIVATE }, + updateMask: ["visibility"], + }), + ); + + fireEvent.click(screen.getByRole("button", { name: "memo.share.open-image" })); + expect(onShareImageOpen).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "memo.share.open-panel" })); + expect(screen.getByTestId("share-panel")).toBeInTheDocument(); + }); +}); diff --git a/web/tests/paged-memo-list.test.tsx b/web/tests/paged-memo-list.test.tsx index 929a884b..785373c1 100644 --- a/web/tests/paged-memo-list.test.tsx +++ b/web/tests/paged-memo-list.test.tsx @@ -8,6 +8,7 @@ const view = vi.hoisted(() => ({ maxColumns: 1 as 0 | 1 | 2 | 3, compactMode: fa const feed = vi.hoisted(() => ({ memos: [] as unknown[], hasNextPage: false, + isLoading: false, fetchNextPage: vi.fn(async () => undefined), })); const readiness = vi.hoisted(() => ({ auth: true, instance: true })); @@ -18,7 +19,7 @@ vi.mock("@/hooks/useMemoQueries", () => ({ fetchNextPage: feed.fetchNextPage, hasNextPage: feed.hasNextPage, isFetchingNextPage: false, - isLoading: false, + isLoading: feed.isLoading, }), })); @@ -68,6 +69,7 @@ describe("", () => { view.compactMode = false; feed.memos = []; feed.hasNextPage = false; + feed.isLoading = false; feed.fetchNextPage.mockClear(); readiness.auth = true; readiness.instance = true; @@ -100,6 +102,22 @@ describe("", () => { } }); + it("delays the initial loading spinner to avoid flashing on fast loads", async () => { + vi.useFakeTimers(); + try { + feed.isLoading = true; + const { container } = renderList(); + + expect(container.querySelector(".animate-spin")).not.toBeInTheDocument(); + await act(async () => vi.advanceTimersByTimeAsync(249)); + expect(container.querySelector(".animate-spin")).not.toBeInTheDocument(); + await act(async () => vi.advanceTimersByTimeAsync(1)); + expect(container.querySelector(".animate-spin")).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + it("uses the tile sprite Placeholder for the empty state", () => { renderList();