package msglib import ( "context" "encoding/json" "errors" "fmt" "os" "strings" "testing" "time" ) func TestSelfCheckSendsProtocolEnvelopeAndDecodesResponse(t *testing.T) { client := newFakeClient(t, "self_check_ok", Config{}) got, err := client.SelfCheck(context.Background()) if err != nil { t.Fatalf("SelfCheck returned error: %v", err) } if got.SidecarName != "MsgLibReadSidecar" { t.Fatalf("SidecarName = %q, want MsgLibReadSidecar", got.SidecarName) } if got.Protocol != Protocol { t.Fatalf("Protocol = %q, want %q", got.Protocol, Protocol) } if got.ProcessBits != 32 || !got.ReadOnly || got.MutatesDB { t.Fatalf("unexpected safety fields: bits=%d read_only=%v mutates_db=%v", got.ProcessBits, got.ReadOnly, got.MutatesDB) } } func TestDisplaySourcesPassesConfiguredPathsAndDecodesMetadata(t *testing.T) { cfg := Config{ SQLiteDLLPath: `C:\fixture\System.Data.SQLite.dll`, DBPath: `C:\fixture\MsgLib.db`, Password: "123", } client := newFakeClient(t, "display_sources_ok", cfg) got, err := client.DisplaySources(context.Background()) if err != nil { t.Fatalf("DisplaySources returned error: %v", err) } if len(got) != 2 { t.Fatalf("len(DisplaySources) = %d, want 2", len(got)) } if got[0].Source != "contacts_roster" || got[0].Table != "TD_Roster" || !got[0].Available { t.Fatalf("unexpected first display source: %+v", got[0]) } if strings.Join(got[0].RequiredColumns, ",") != "UserJID,Name" { t.Fatalf("required columns = %#v", got[0].RequiredColumns) } if got[1].Source != "work_group_auth" || got[1].Available { t.Fatalf("unexpected second display source: %+v", got[1]) } } func TestMessageSourcesPassesConfiguredPathsAndDecodesMetadata(t *testing.T) { cfg := Config{ SQLiteDLLPath: `C:\fixture\System.Data.SQLite.dll`, DBPath: `C:\fixture\MsgLib.db`, Password: "123", } client := newFakeClient(t, "message_sources_ok", cfg) got, err := client.MessageSources(context.Background()) if err != nil { t.Fatalf("MessageSources returned error: %v", err) } if len(got) != 2 { t.Fatalf("len(MessageSources) = %d, want 2", len(got)) } if got[0].Source != "direct_messages" || got[0].Table != "tblPersonMsg" || got[0].Role != "direct" || !got[0].Available { t.Fatalf("unexpected first message source: %+v", got[0]) } if got[0].RowCount != "2132" || strings.Join(got[0].RequiredColumns, ",") != "Id,SndPersonId,RecvPersonId,ObjPersonId,ActionTime,MsgText,MessageBody" { t.Fatalf("unexpected first message source metadata: %+v", got[0]) } if got[1].Source != "group_messages" || got[1].Table != "tblMsgGroupPersonMsg" || got[1].Role != "group" || got[1].RowCount != "2884" { t.Fatalf("unexpected second message source: %+v", got[1]) } } func TestListMessagesPassesBoundedArgsAndDecodesNormalizedMessages(t *testing.T) { cfg := Config{ SQLiteDLLPath: `C:\fixture\System.Data.SQLite.dll`, DBPath: `C:\fixture\MsgLib.db`, Password: "123", } client := newFakeClient(t, "list_messages_ok", cfg) got, err := client.ListMessages(context.Background(), ListMessagesOptions{ ConversationType: "direct", Query: "redacted", Since: "2026-07-10T00:00:00Z", Limit: 99, IncludeBody: false, IncludeAttachmentMetadata: true, }) if err != nil { t.Fatalf("ListMessages returned error: %v", err) } if !got.ReadOnly || got.MessageBodyValuesReturned || got.RawRowsReturned || got.FilePathsReturned { t.Fatalf("unexpected safety flags: %+v", got) } if strings.Join(got.SourceTables, ",") != "tblPersonMsg" { t.Fatalf("SourceTables = %#v", got.SourceTables) } if len(got.Messages) != 1 { t.Fatalf("len(Messages) = %d, want 1", len(got.Messages)) } msg := got.Messages[0] if msg.MessageID != "msg-redacted-1" || msg.ConversationType != "direct" || msg.RawRef != "msglib:tblPersonMsg" { t.Fatalf("unexpected message identity: %+v", msg) } if msg.ContentText != "" || msg.Text != "" { t.Fatalf("content should be omitted when include_body=false: %+v", msg) } if len(msg.Attachments) != 1 || msg.Attachments[0].FileName != "redacted.docx" || msg.Attachments[0].DownloadRef != "" { t.Fatalf("unexpected attachments: %+v", msg.Attachments) } } func TestDisplayEntitiesPassesBoundedArgsAndDecodesMetadata(t *testing.T) { cfg := Config{ SQLiteDLLPath: `C:\fixture\System.Data.SQLite.dll`, DBPath: `C:\fixture\MsgLib.db`, Password: "123", } client := newFakeClient(t, "display_entities_ok", cfg) got, err := client.DisplayEntities(context.Background(), DisplayEntitiesOptions{EntityType: "contacts", Query: "alice", Limit: 2}) if err != nil { t.Fatalf("DisplayEntities returned error: %v", err) } if len(got) != 2 { t.Fatalf("len(DisplayEntities) = %d, want 2", len(got)) } if got[0].EntityType != "contacts" || got[0].SourceTable != "TD_Roster" || got[0].JID != "alice@example" || got[0].DisplayName != "Alice" { t.Fatalf("unexpected first entity: %+v", got[0]) } if got[0].Confidence <= 0 || strings.Join(got[0].MatchedColumns, ",") != "JId,Nick" { t.Fatalf("unexpected first entity metadata: %+v", got[0]) } if got[1].SourceTable != "tblRecent" || got[1].JID != "recent@example" || got[1].DisplayName != "Recent Alice" { t.Fatalf("unexpected second entity: %+v", got[1]) } } func TestDisplayEntitiesRejectsUnsupportedEntityType(t *testing.T) { client := newFakeClient(t, "display_entities_ok", Config{}) _, err := client.DisplayEntities(context.Background(), DisplayEntitiesOptions{EntityType: "messages", Limit: 5}) if err == nil { t.Fatal("DisplayEntities returned nil error, want unsupported entity_type error") } if !strings.Contains(err.Error(), "entity_type") { t.Fatalf("DisplayEntities error = %v, want entity_type context", err) } } func TestClientReturnsStructuredSidecarError(t *testing.T) { client := newFakeClient(t, "sidecar_error", Config{}) _, err := client.SelfCheck(context.Background()) if err == nil { t.Fatal("SelfCheck returned nil error, want sidecar error") } var sidecarErr *SidecarError if !errors.As(err, &sidecarErr) { t.Fatalf("error %T %[1]v does not wrap *SidecarError", err) } if sidecarErr.Code != "DB_OPEN_FAILED" || !strings.Contains(sidecarErr.Message, "read-only open failed") { t.Fatalf("unexpected sidecar error: %+v", sidecarErr) } } func TestClientTimeoutStopsSidecar(t *testing.T) { client := newFakeClient(t, "hang", Config{Timeout: 150 * time.Millisecond}) _, err := client.SelfCheck(context.Background()) if err == nil { t.Fatal("SelfCheck returned nil error, want timeout") } if !errors.Is(err, context.DeadlineExceeded) && !strings.Contains(strings.ToLower(err.Error()), "deadline") { t.Fatalf("SelfCheck error = %v, want deadline exceeded", err) } } func TestConfigFromEnvReadsSidecarShape(t *testing.T) { t.Setenv("ISPHERE_MSGLIB_SIDECAR_EXE", `C:\tools\MsgLibReadSidecar.exe`) t.Setenv("ISPHERE_MSGLIB_SQLITE_DLL", `C:\tools\System.Data.SQLite.dll`) t.Setenv("ISPHERE_MSGLIB_DB", `C:\copy\MsgLib.db`) cfg, err := ConfigFromEnv() if err != nil { t.Fatalf("ConfigFromEnv returned error: %v", err) } if cfg.SidecarPath != `C:\tools\MsgLibReadSidecar.exe` { t.Fatalf("SidecarPath = %q", cfg.SidecarPath) } if cfg.SQLiteDLLPath != `C:\tools\System.Data.SQLite.dll` || cfg.DBPath != `C:\copy\MsgLib.db` { t.Fatalf("unexpected DB config: %+v", cfg) } if cfg.Password != "123" { t.Fatalf("Password = %q, want default 123", cfg.Password) } } func newFakeClient(t *testing.T, mode string, overrides Config) *Client { t.Helper() exe, err := os.Executable() if err != nil { t.Fatalf("os.Executable: %v", err) } cfg := overrides cfg.SidecarPath = exe cfg.SidecarArgs = []string{"-test.run=TestFakeSidecarProcess", "--"} cfg.ExtraEnv = append(cfg.ExtraEnv, "ISPHERE_MSGLIB_FAKE_SIDECAR=1", "ISPHERE_MSGLIB_FAKE_MODE="+mode) if cfg.Timeout == 0 { cfg.Timeout = 2 * time.Second } return NewClient(cfg) } func TestFakeSidecarProcess(t *testing.T) { if os.Getenv("ISPHERE_MSGLIB_FAKE_SIDECAR") != "1" { return } os.Exit(fakeSidecarMain()) } func fakeSidecarMain() int { mode := os.Getenv("ISPHERE_MSGLIB_FAKE_MODE") if mode == "hang" { time.Sleep(5 * time.Second) return 0 } var req map[string]any if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil { fmt.Fprintf(os.Stderr, "decode request: %v\n", err) return 2 } protocol, _ := req["protocol"].(string) op, _ := req["op"].(string) requestID, _ := req["request_id"].(string) if protocol != Protocol || op == "" || requestID == "" { fmt.Fprintf(os.Stderr, "bad envelope: protocol=%q op=%q request_id=%q\n", protocol, op, requestID) return 2 } if mode == "sidecar_error" { return writeFakeResponse(map[string]any{ "protocol": Protocol, "request_id": requestID, "op": op, "ok": false, "data": nil, "error": map[string]any{ "code": "DB_OPEN_FAILED", "message": "read-only open failed", }, "warnings": []any{}, }) } switch op { case "self_check": return writeFakeResponse(map[string]any{ "protocol": Protocol, "request_id": requestID, "op": op, "ok": true, "data": map[string]any{ "sidecar_name": "MsgLibReadSidecar", "sidecar_version": "0.1.0", "protocol": Protocol, "process_bits": 32, "read_only": true, "mutates_db": false, }, "error": nil, "warnings": []any{}, }) case "display_sources": args, _ := req["args"].(map[string]any) if args["sqlite_dll_path"] != `C:\fixture\System.Data.SQLite.dll` || args["db_path"] != `C:\fixture\MsgLib.db` || args["password"] != "123" { fmt.Fprintf(os.Stderr, "bad display_sources args: %#v\n", args) return 2 } return writeFakeResponse(map[string]any{ "protocol": Protocol, "request_id": requestID, "op": op, "ok": true, "data": map[string]any{ "metadata_only": true, "read_only": true, "message_body_values_returned": false, "display_sources": []any{ map[string]any{"source": "contacts_roster", "table": "TD_Roster", "available": true, "required_columns": []any{"UserJID", "Name"}, "present_columns": []any{"UserJID", "Name", "Mobile"}}, map[string]any{"source": "work_group_auth", "table": "TD_WorkGroupAuth", "available": false, "required_columns": []any{"GroupJID", "GroupName"}, "present_columns": []any{"GroupJID"}}, }, }, "error": nil, "warnings": []any{}, }) case "message_sources": args, _ := req["args"].(map[string]any) if args["sqlite_dll_path"] != `C:\fixture\System.Data.SQLite.dll` || args["db_path"] != `C:\fixture\MsgLib.db` || args["password"] != "123" { fmt.Fprintf(os.Stderr, "bad message_sources args: %#v\n", args) return 2 } return writeFakeResponse(map[string]any{ "protocol": Protocol, "request_id": requestID, "op": op, "ok": true, "data": map[string]any{ "metadata_only": true, "read_only": true, "message_body_values_returned": false, "raw_rows_returned": false, "file_paths_returned": false, "message_sources": []any{ map[string]any{ "source": "direct_messages", "table": "tblPersonMsg", "role": "direct", "available": true, "required_columns": []any{"Id", "SndPersonId", "RecvPersonId", "ObjPersonId", "ActionTime", "MsgText", "MessageBody"}, "present_columns": []any{"Id", "SndPersonId", "RecvPersonId", "ObjPersonId", "ActionTime", "MsgText", "MessageBody"}, "row_count": "2132", }, map[string]any{ "source": "group_messages", "table": "tblMsgGroupPersonMsg", "role": "group", "available": true, "required_columns": []any{"Id", "SndPersonId", "MsgGroupId", "MsgGroupName", "MsgTime", "MsgText", "MessageBody"}, "present_columns": []any{"Id", "SndPersonId", "MsgGroupId", "MsgGroupName", "MsgTime", "MsgText", "MessageBody"}, "row_count": "2884", }, }, }, "error": nil, "warnings": []any{}, }) case "list_messages": args, _ := req["args"].(map[string]any) if args["sqlite_dll_path"] != `C:\fixture\System.Data.SQLite.dll` || args["db_path"] != `C:\fixture\MsgLib.db` || args["password"] != "123" || args["conversation_type"] != "direct" || args["query"] != "redacted" || args["since"] != "2026-07-10T00:00:00Z" || args["limit"] != float64(50) || args["include_body"] != false || args["include_attachment_metadata"] != true { fmt.Fprintf(os.Stderr, "bad list_messages args: %#v\n", args) return 2 } return writeFakeResponse(map[string]any{ "protocol": Protocol, "request_id": requestID, "op": op, "ok": true, "data": map[string]any{ "read_only": true, "message_body_values_returned": false, "raw_rows_returned": false, "file_paths_returned": false, "source_tables": []any{"tblPersonMsg"}, "messages": []any{ map[string]any{ "message_id": "msg-redacted-1", "id": "msg-redacted-1", "conversation_id": "contact-redacted", "conversation_type": "direct", "sender_id": "sender-redacted", "sender_name": "Sender Redacted", "receiver_id": "receiver-redacted", "text": "", "content_text": "", "content_type": "file", "timestamp": "2026-07-10T00:01:02Z", "created_at": "2026-07-10T00:01:02Z", "read": true, "source": "local_readonly", "raw_ref": "msglib:tblPersonMsg", "attachments": []any{ map[string]any{"file_id": "msg-redacted-1:redacted.docx", "file_name": "redacted.docx", "size_bytes": float64(1234), "download_ref": ""}, }, }, }, }, "error": nil, "warnings": []any{}, }) case "display_entities": args, _ := req["args"].(map[string]any) if args["sqlite_dll_path"] != `C:\fixture\System.Data.SQLite.dll` || args["db_path"] != `C:\fixture\MsgLib.db` || args["password"] != "123" || args["entity_type"] != "contacts" || args["query"] != "alice" || args["limit"] != float64(2) { fmt.Fprintf(os.Stderr, "bad display_entities args: %#v\n", args) return 2 } return writeFakeResponse(map[string]any{ "protocol": Protocol, "request_id": requestID, "op": op, "ok": true, "data": map[string]any{ "metadata_only": true, "read_only": true, "entity_type": "contacts", "limit": 2, "result_count": 2, "entities": []any{ map[string]any{"entity_type": "contacts", "source_table": "TD_Roster", "jid": "alice@example", "display_name": "Alice", "confidence": 0.95, "matched_columns": []any{"JId", "Nick"}}, map[string]any{"entity_type": "contacts", "source_table": "tblRecent", "jid": "recent@example", "display_name": "Recent Alice", "confidence": 0.70, "matched_columns": []any{"PersonId", "PersonName"}}, }, }, "error": nil, "warnings": []any{}, }) default: fmt.Fprintf(os.Stderr, "unexpected op: %s\n", op) return 2 } } func writeFakeResponse(resp map[string]any) int { if err := json.NewEncoder(os.Stdout).Encode(resp); err != nil { fmt.Fprintf(os.Stderr, "encode response: %v\n", err) return 2 } return 0 }