@@ -19,6 +19,7 @@ describe('MessagesQueryService', () => {
1919 const mockQb : Record < string , jest . Mock > = {
2020 select : jest . fn ( ) ,
2121 addSelect : jest . fn ( ) ,
22+ distinct : jest . fn ( ) ,
2223 leftJoin : jest . fn ( ) ,
2324 where : jest . fn ( ) ,
2425 andWhere : jest . fn ( ) ,
@@ -35,6 +36,7 @@ describe('MessagesQueryService', () => {
3536 const chainableMethods = [
3637 'select' ,
3738 'addSelect' ,
39+ 'distinct' ,
3840 'leftJoin' ,
3941 'where' ,
4042 'andWhere' ,
@@ -698,6 +700,180 @@ describe('MessagesQueryService', () => {
698700
699701 expect ( result . providers ) . toEqual ( [ 'anthropic' , 'gemini' , 'openai' ] ) ;
700702 } ) ;
703+
704+ /**
705+ * These tests cover the new stored-provider path:
706+ * 1. getDistinctModels collects providers from the distinct rows
707+ * 2. deriveProviders merges stored providers with inferred-from-model providers
708+ * 3. getMessages provider filter ORs on at.provider = ? AND legacy model match
709+ */
710+ describe ( 'stored provider column' , ( ) => {
711+ it ( 'derives provider from the stored provider column when present' , async ( ) => {
712+ mockGetRawOne . mockResolvedValueOnce ( { total : 1 } ) ;
713+ mockGetRawMany
714+ . mockResolvedValueOnce ( [
715+ {
716+ id : 'msg-1' ,
717+ timestamp : '2026-02-16 10:00:00' ,
718+ model : 'gemma4:31b' ,
719+ provider : 'ollama-cloud' ,
720+ } ,
721+ ] )
722+ . mockResolvedValueOnce ( [ { model : 'gemma4:31b' , provider : 'ollama-cloud' } ] ) ;
723+
724+ const result = await service . getMessages ( {
725+ range : '24h' ,
726+ userId : 'test-user' ,
727+ limit : 20 ,
728+ } ) ;
729+
730+ // Without the stored provider, inferProviderFromModel would return
731+ // `ollama` for `gemma4:31b` (tagless colon heuristic). The stored value
732+ // takes precedence.
733+ expect ( result . providers ) . toEqual ( expect . arrayContaining ( [ 'ollama-cloud' ] ) ) ;
734+ expect ( result . providers ) . toContain ( 'ollama' ) ;
735+ } ) ;
736+
737+ it ( 'merges stored providers with providers inferred from legacy rows' , async ( ) => {
738+ mockGetRawOne . mockResolvedValueOnce ( { total : 2 } ) ;
739+ mockGetRawMany
740+ . mockResolvedValueOnce ( [
741+ {
742+ id : 'msg-1' ,
743+ timestamp : '2026-02-16 10:00:00' ,
744+ model : 'deepseek-v3.2' ,
745+ provider : 'ollama-cloud' ,
746+ } ,
747+ {
748+ id : 'msg-2' ,
749+ timestamp : '2026-02-16 09:00:00' ,
750+ model : 'gpt-4o' ,
751+ provider : null ,
752+ } ,
753+ ] )
754+ . mockResolvedValueOnce ( [
755+ { model : 'deepseek-v3.2' , provider : 'ollama-cloud' } ,
756+ { model : 'gpt-4o' , provider : null } ,
757+ ] ) ;
758+
759+ const result = await service . getMessages ( {
760+ range : '24h' ,
761+ userId : 'test-user' ,
762+ limit : 20 ,
763+ } ) ;
764+
765+ // deepseek-v3.2 is stored with ollama-cloud; gpt-4o has no stored
766+ // provider so it falls back to inference → openai.
767+ expect ( result . providers . sort ( ) ) . toEqual ( [ 'deepseek' , 'ollama-cloud' , 'openai' ] ) ;
768+ } ) ;
769+
770+ it ( 'skips null and empty provider values in distinct rows' , async ( ) => {
771+ mockGetRawOne . mockResolvedValueOnce ( { total : 1 } ) ;
772+ mockGetRawMany
773+ . mockResolvedValueOnce ( [ { id : 'msg-1' , timestamp : '2026-02-16 10:00:00' , model : 'gpt-4o' } ] )
774+ // Include rows with null and empty-string provider values to cover
775+ // the `providerValue != null && providerValue !== ''` branch.
776+ . mockResolvedValueOnce ( [
777+ { model : 'gpt-4o' , provider : null } ,
778+ { model : 'claude-opus-4-6' , provider : '' } ,
779+ { model : 'deepseek-v3.2' , provider : 'ollama-cloud' } ,
780+ ] ) ;
781+
782+ const result = await service . getMessages ( {
783+ range : '24h' ,
784+ userId : 'test-user' ,
785+ limit : 20 ,
786+ } ) ;
787+
788+ // The null and '' providers must not create spurious entries; only the
789+ // real ollama-cloud entry plus the model-name-inferred ones.
790+ expect ( result . providers ) . toContain ( 'ollama-cloud' ) ;
791+ expect ( result . providers ) . toContain ( 'openai' ) ;
792+ expect ( result . providers ) . toContain ( 'anthropic' ) ;
793+ expect ( result . providers ) . not . toContain ( '' ) ;
794+ } ) ;
795+
796+ it ( 'derives providers skipping null entries in stored list' , async ( ) => {
797+ // Cover deriveProviders() line where `if (p) seen.add(p)` guards against
798+ // null values surfacing in the stored providers array.
799+ const derive = (
800+ service as unknown as {
801+ deriveProviders : ( m : string [ ] , p : string [ ] ) => string [ ] ;
802+ }
803+ ) . deriveProviders . bind ( service ) ;
804+
805+ // Intentionally pass a null inside the array to simulate a row that had
806+ // provider = null. The TtlCache contract uses string[], but the guard
807+ // defends against legacy or corrupted cached entries.
808+ const result = derive (
809+ [ 'gpt-4o' ] ,
810+ [ 'anthropic' , null as unknown as string , '' , 'ollama-cloud' ] ,
811+ ) ;
812+ expect ( result ) . toEqual ( [ 'anthropic' , 'ollama-cloud' , 'openai' ] ) ;
813+ } ) ;
814+
815+ it ( 'provider filter: ORs stored provider = ? with legacy model IN (...)' , async ( ) => {
816+ // getDistinctModels returns a mix of models and providers.
817+ // `matching` will include gpt-4o (inferred as openai), so the OR branch
818+ // with at.provider IS NULL AND at.model IN (...) is built.
819+ mockGetRawMany . mockResolvedValueOnce ( [
820+ { model : 'gpt-4o' , provider : 'openai' } ,
821+ { model : 'gpt-4.1' , provider : null } ,
822+ { model : 'claude-opus-4-6' , provider : null } ,
823+ ] ) ;
824+ mockGetRawOne . mockResolvedValueOnce ( { total : 2 } ) ;
825+ mockGetRawMany
826+ . mockResolvedValueOnce ( [
827+ {
828+ id : 'msg-1' ,
829+ timestamp : '2026-02-16 10:00:00' ,
830+ model : 'gpt-4o' ,
831+ provider : 'openai' ,
832+ } ,
833+ {
834+ id : 'msg-2' ,
835+ timestamp : '2026-02-16 09:00:00' ,
836+ model : 'gpt-4.1' ,
837+ provider : null ,
838+ } ,
839+ ] )
840+ . mockResolvedValueOnce ( [
841+ { model : 'gpt-4o' , provider : 'openai' } ,
842+ { model : 'gpt-4.1' , provider : null } ,
843+ { model : 'claude-opus-4-6' , provider : null } ,
844+ ] ) ;
845+
846+ const result = await service . getMessages ( {
847+ range : '24h' ,
848+ userId : 'test-user' ,
849+ limit : 20 ,
850+ provider : 'openai' ,
851+ } ) ;
852+
853+ expect ( result . total_count ) . toBe ( 2 ) ;
854+ expect ( result . items ) . toHaveLength ( 2 ) ;
855+ } ) ;
856+
857+ it ( 'provider filter: uses only the stored provider branch when no legacy models match' , async ( ) => {
858+ // All distinct models map to something other than the requested provider
859+ // (e.g. the legacy OR branch would be empty), exercising the matching.length === 0 path.
860+ mockGetRawMany . mockResolvedValueOnce ( [ { model : 'gpt-4o' , provider : 'openai' } ] ) ;
861+ mockGetRawOne . mockResolvedValueOnce ( { total : 0 } ) ;
862+ mockGetRawMany
863+ . mockResolvedValueOnce ( [ ] )
864+ . mockResolvedValueOnce ( [ { model : 'gpt-4o' , provider : 'openai' } ] ) ;
865+
866+ const result = await service . getMessages ( {
867+ range : '24h' ,
868+ userId : 'test-user' ,
869+ limit : 20 ,
870+ provider : 'anthropic' ,
871+ } ) ;
872+
873+ expect ( result . total_count ) . toBe ( 0 ) ;
874+ expect ( result . items ) . toEqual ( [ ] ) ;
875+ } ) ;
876+ } ) ;
701877} ) ;
702878
703879describe ( 'MessagesQueryService (sql.js / local mode)' , ( ) => {
@@ -714,6 +890,7 @@ describe('MessagesQueryService (sql.js / local mode)', () => {
714890 const mockQb = {
715891 select : jest . fn ( ) . mockReturnThis ( ) ,
716892 addSelect : mockAddSelect ,
893+ distinct : jest . fn ( ) . mockReturnThis ( ) ,
717894 leftJoin : jest . fn ( ) . mockReturnThis ( ) ,
718895 where : jest . fn ( ) . mockReturnThis ( ) ,
719896 andWhere : jest . fn ( ) . mockReturnThis ( ) ,
0 commit comments